From a40136594316e2c9ed584e1806681ac6cae0a582 Mon Sep 17 00:00:00 2001 From: Jamey Greenwood Date: Tue, 30 Dec 2025 21:35:52 +0000 Subject: [PATCH] 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) --- SIMPLESHOP_THEME_STUDIO_SPEC.md | 423 ++ lib/simpleshop_theme/media.ex | 92 + lib/simpleshop_theme/media/image.ex | 54 + lib/simpleshop_theme/settings.ex | 133 + lib/simpleshop_theme/settings/setting.ex | 24 + .../settings/theme_settings.ex | 101 + lib/simpleshop_theme/theme/presets.ex | 135 + .../20251230213057_create_settings.exs | 16 + .../20251230213058_create_images.exs | 21 + priv/repo/seeds.exs | 9 + test/simpleshop_theme/media_test.exs | 113 + test/simpleshop_theme/settings_test.exs | 109 + theme-demo-v28.html | 6278 +++++++++++++++++ 13 files changed, 7508 insertions(+) create mode 100644 SIMPLESHOP_THEME_STUDIO_SPEC.md create mode 100644 lib/simpleshop_theme/media.ex create mode 100644 lib/simpleshop_theme/media/image.ex create mode 100644 lib/simpleshop_theme/settings.ex create mode 100644 lib/simpleshop_theme/settings/setting.ex create mode 100644 lib/simpleshop_theme/settings/theme_settings.ex create mode 100644 lib/simpleshop_theme/theme/presets.ex create mode 100644 priv/repo/migrations/20251230213057_create_settings.exs create mode 100644 priv/repo/migrations/20251230213058_create_images.exs create mode 100644 test/simpleshop_theme/media_test.exs create mode 100644 test/simpleshop_theme/settings_test.exs create mode 100644 theme-demo-v28.html diff --git a/SIMPLESHOP_THEME_STUDIO_SPEC.md b/SIMPLESHOP_THEME_STUDIO_SPEC.md new file mode 100644 index 0000000..8f09311 --- /dev/null +++ b/SIMPLESHOP_THEME_STUDIO_SPEC.md @@ -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" diff --git a/lib/simpleshop_theme/media.ex b/lib/simpleshop_theme/media.ex new file mode 100644 index 0000000..2b36c50 --- /dev/null +++ b/lib/simpleshop_theme/media.ex @@ -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 diff --git a/lib/simpleshop_theme/media/image.ex b/lib/simpleshop_theme/media/image.ex new file mode 100644 index 0000000..4ee0fdb --- /dev/null +++ b/lib/simpleshop_theme/media/image.ex @@ -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 diff --git a/lib/simpleshop_theme/settings.ex b/lib/simpleshop_theme/settings.ex new file mode 100644 index 0000000..cdc25bc --- /dev/null +++ b/lib/simpleshop_theme/settings.ex @@ -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 diff --git a/lib/simpleshop_theme/settings/setting.ex b/lib/simpleshop_theme/settings/setting.ex new file mode 100644 index 0000000..61fbb2b --- /dev/null +++ b/lib/simpleshop_theme/settings/setting.ex @@ -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 diff --git a/lib/simpleshop_theme/settings/theme_settings.ex b/lib/simpleshop_theme/settings/theme_settings.ex new file mode 100644 index 0000000..8ae57d0 --- /dev/null +++ b/lib/simpleshop_theme/settings/theme_settings.ex @@ -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 diff --git a/lib/simpleshop_theme/theme/presets.ex b/lib/simpleshop_theme/theme/presets.ex new file mode 100644 index 0000000..2ec249e --- /dev/null +++ b/lib/simpleshop_theme/theme/presets.ex @@ -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 diff --git a/priv/repo/migrations/20251230213057_create_settings.exs b/priv/repo/migrations/20251230213057_create_settings.exs new file mode 100644 index 0000000..81dbb42 --- /dev/null +++ b/priv/repo/migrations/20251230213057_create_settings.exs @@ -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 diff --git a/priv/repo/migrations/20251230213058_create_images.exs b/priv/repo/migrations/20251230213058_create_images.exs new file mode 100644 index 0000000..6e6b602 --- /dev/null +++ b/priv/repo/migrations/20251230213058_create_images.exs @@ -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 diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index d60342f..796653e 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,12 @@ # # We recommend using the bang functions (`insert!`, `update!` # 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)") diff --git a/test/simpleshop_theme/media_test.exs b/test/simpleshop_theme/media_test.exs new file mode 100644 index 0000000..ec88fc5 --- /dev/null +++ b/test/simpleshop_theme/media_test.exs @@ -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: "" + } + + 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 diff --git a/test/simpleshop_theme/settings_test.exs b/test/simpleshop_theme/settings_test.exs new file mode 100644 index 0000000..dfe3c0e --- /dev/null +++ b/test/simpleshop_theme/settings_test.exs @@ -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 diff --git a/theme-demo-v28.html b/theme-demo-v28.html new file mode 100644 index 0000000..4244449 --- /dev/null +++ b/theme-demo-v28.html @@ -0,0 +1,6278 @@ + + + + + + SimpleShop Theme Studio v28 + + + + + + + + + +
+ +
+
+

Theme Studio

+

One theme, infinite possibilities. Every combination is designed to work beautifully.

+
+ + +
+ + +
+ + +
+ + +
+ + + + + + + + + +
+ +
+ +
+ Upload logo (SVG or PNG) +
+ +
+ Logo preview + +
+
+
+
+ Logo size + 36px +
+ +
+ +
+
+ +
+
+ + #171717 +
+
+
+ + +
+ Upload header image +
+ +
+
+ Header preview + +
+ +
+
+
+ Zoom + 100% +
+ +
+ +
+
+ Horizontal position + 50% +
+ +
+ +
+
+ Vertical position + 50% +
+ +
+
+
+
+
+ + +
+ +
+ + + + + + + + + +
+
+ + +
+ +
+ + #f97316 +
+
+ + +
+
+ Customise + + + +
+ +
+ +
+
+ + + + + + Typography +
+ +
+ +
+ + + + + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ + +
+
+ + + + + Colours +
+ +
+ +
+ + + + +
+
+ +
+ +
+ + #ea580c +
+
+ +
+ +
+ + #dc2626 +
+
+
+ + +
+
+ + + + + + Layout +
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+
+ + +
+
+ + + + + + Product cards +
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + +
+
+
+ + +
+
+ + + + + Product page +
+ +
+ + +
+ +
+ +
+ + + +
+
+
+ + +
+
+ + + + + Header +
+ +
+ +
+ + + +
+
+ +
+ +
+ + +
+
+
+ +
+
+ + +
+
Current combination
+
Neutral · Clean · Soft · Balanced · 4-up · Standard
+
One of 100,000+ possible combinations
+
+
+ + +
+
+
+ + + + + + + +
+ +
+
+ + + +
+
+ + +
+
+ + + + +
+ B +
+ botanicalstudio.simpleshop.uk +
+
+ +
+
+ +
+ + +
+

Free UK shipping on orders over £50 ✨

+
+ + +
+ + +
+ + +
+
+ + +
+ +
+

Nature-inspired art prints

+

Original botanical illustrations, printed on premium archival paper. Each piece brings a little bit of the outside, inside.

+ +
+ + +
+ +
+ + +
+

Featured prints

+
+
+
+ New +
+
+ +
+
+

Autumn Fern

+

£24.00

+
+
+
+
+ Sale +
+
+ +
+
+

Wild Roses

+

£35.00 £28.00

+
+
+
+
+
+
+ +
+
+

Morning Dew

+

£24.00

+
+
+
+
+
+
+ +
+
+

Spring Bloom

+

£28.00

+
+
+
+
+ +
+
+ + +
+
+
+

Made with care in Yorkshire

+

Every illustration starts as a pencil sketch, inspired by the plants and flowers found in British woodlands, meadows, and gardens. Printed on museum-quality archival paper using pigment-based inks that will last a lifetime.

+ Learn more about the studio → +
+
+ + + +
+ + +
+
+

Shop All

+

12 products

+
+ +
+
+
+ + + + +
+
+ + +
+
+ +
+
+
+ New +
+
+ +
+
+

Autumn Fern

+

£24.00

+
+
+
+
+ Sale +
+
+ +
+
+

Wild Roses

+

£35.00 £28.00

+
+
+
+
+
+
+ +
+
+

Morning Dew

+

£24.00

+
+
+
+
+ Sold out +
+
+
+
+

Oak Leaves

+

£32.00

+
+
+
+
+
+
+ +
+
+

Spring Bloom

+

£28.00

+
+
+
+
+
+
+ +
+
+

Lavender Fields

+

£24.00

+
+
+
+
+ +
+ +
+
+ + +
+
+ + +
+ + +
+

Autumn Fern

+

£24.00

+ +
+

A delicate botanical illustration capturing the intricate fronds of an autumn fern. Each piece is printed on museum-quality 300gsm cotton rag paper using archival inks.

+
+ +
+
+ +
+ + + +
+
+ +
+ +
+ + + + +
+
+
+ +
+
+ + Size guide + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
SizeDimensionsBest for
A514.8 × 21 cmDesks, shelves
A421 × 29.7 cmSmall wall spaces
A329.7 × 42 cmStatement pieces
+
+
+ +
+ + Materials & quality + + + + +
+
    +
  • Printed on 300gsm museum-quality cotton rag paper
  • +
  • Archival pigment inks rated 100+ years
  • +
  • FSC-certified sustainable paper
  • +
  • Each print is hand-checked before shipping
  • +
+
+
+ +
+ + Shipping & returns + + + + +
+
    +
  • Free UK shipping on orders over £50
  • +
  • Dispatched within 2-3 business days
  • +
  • Plastic-free packaging
  • +
  • 30-day returns for unused items
  • +
+
+
+
+ + + +
+
+ + + + + Printed in the UK +
+
+ + + + + + + Free UK shipping over £50 +
+
+ +
+
+ + + + + Secure checkout +
+
+ + + + + UK based +
+
+ + + + Quality guaranteed +
+
+
+
+ + +
+
+

Customer reviews

+
+
+ + + + + +
+ Based on 24 reviews +
+
+ +
+
+
+
+ + + + + +
+ 2 weeks ago +
+

Absolutely beautiful

+

The quality exceeded my expectations. The colours are vibrant and the paper feels premium. It's now pride of place in my living room.

+
+ Sarah M. + Verified purchase +
+
+ +
+
+
+ + + + + +
+ 1 month ago +
+

Great gift

+

Bought this as a gift and it arrived beautifully packaged. Fast shipping too. Would definitely order again.

+
+ James T. + Verified purchase +
+
+
+ + +
+ + + +
+ + + +
+ + +
+
+
+

Your basket

+ +
+ + +
+
+
+
+
+
+

Autumn Fern

+

A4 / Unframed

+

£24.00

+
+
+ + 1 + +
+ +
+ +
+
+
+

Wild Roses

+

A3 / Oak frame

+

£48.00

+
+
+ + 1 + +
+ +
+
+ +
+
+ Subtotal + £72.00 +
+
+ Shipping + Calculated at checkout +
+
+ Total + £72.00 +
+ +

+ + + + + Secure checkout powered by Stripe +

+
+
+
+ + + +
+ +
+ +
+
+ + +
+
+
+

About the studio

+

Nature-inspired art, made with care

+
+ +
+
+ +
+

Botanical Studio was born from a love of the natural world and a desire to bring its beauty indoors.

+ +

Every illustration starts as a pencil sketch, inspired by the plants and flowers found in British woodlands, meadows, and gardens. These sketches are then refined and printed on museum-quality archival paper using pigment-based inks that will last a lifetime.

+ +

Based in the Yorkshire countryside, I work from a small garden studio surrounded by the very nature that inspires each piece. Every print is checked by hand before being carefully packaged and sent on its way.

+ +

Sustainability

+

I believe beautiful art shouldn't cost the earth. All prints are produced on FSC-certified paper, shipped in plastic-free packaging, and printed locally to reduce transport emissions.

+ +

The process

+

Each botanical illustration takes between 20-40 hours to complete, from initial field sketches through to the final digital refinement. I work primarily in graphite and watercolour, which are then scanned and carefully colour-corrected to ensure the prints match the original artwork.

+
+
+
+ +
+ +
+
+ + +
+
+
+

Get in touch

+

I'd love to hear from you

+
+ +
+
+
+

General enquiries

+

For questions about prints, shipping, or custom orders.

+ hello@botanicalstudio.com +
+ +
+

Response time

+

I typically respond within 1-2 business days. For urgent order queries, please include your order number.

+
+ +
+

Follow along

+

See works in progress and behind-the-scenes on Instagram.

+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +
+ +
+
+ + +
+
+
+
+ + + + + + + + + + + + + + + + + + +
+

Something went wrong

+

We couldn't find what you were looking for. The page may have moved, or perhaps it never existed in the first place.

+
+ + +
+
+
+ +
+ +
+
+ + +
+
+

Your basket

+ +
+
+
+
+
+

Autumn Fern

+

A4 / Unframed

+

£24.00

+
+
+
+
+
+

Wild Roses

+

A3 / Oak frame

+

£48.00

+
+
+
+ +
+
+ + +
+
+
+ + + + + + +
+
+

Try searching for "fern", "roses", or "botanical"

+
+
+
+ + + + + +
+
+
+ + + + \ No newline at end of file