feat: VivesPOS landing on Winter CMS 1.2 — theme + plugin + Dockerfile
Some checks are pending
Module sub-split / Sub-split (push) Waiting to run

- Base: wintercms/winter branch 1.2 (full framework)
- Theme vivespos: Canvas 7 + Bootstrap 5 CDN, custom CSS
- Layout: deferred GTM/GA4 tracking, JSON-LD SoftwareApplication
- Partials: hero (offline-first), features, modes (offline/nube toggle),
  screenshots, pricing (3 planes), comparison, FAQ, CTA
- Plugin VivesPOS.Site with ContactForm
- Dockerfile: PHP 8.2 Apache, port 80, healthcheck
- Added winter/wn-pages, blog, sitemap, seo plugins
- Active theme set to vivespos
This commit is contained in:
2026-08-21 19:29:00 -06:00
commit 1f72193a64
3266 changed files with 531480 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
<?php namespace Cms\Models;
use Model;
use Cms\Classes\Page;
use Cms\Classes\Theme;
use Winter\Storm\Support\Arr;
use Symfony\Component\HttpFoundation\IpUtils;
use ApplicationException;
/**
* Maintenance mode settings
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class MaintenanceSetting extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var array Behaviors implemented by this model.
*/
public $implement = [
\System\Behaviors\SettingsModel::class
];
/**
* @var string Unique code
*/
public $settingsCode = 'cms_maintenance_settings';
/**
* @var mixed Settings form field defitions
*/
public $settingsFields = 'fields.yaml';
/**
* Validation rules
*/
public $rules = [];
/**
* Initialize the seed data for this model. This only executes when the
* model is first created or reset to default.
* @return void
*/
public function initSettingsData()
{
$this->is_enabled = false;
}
public function getCmsPageOptions()
{
if (!$theme = Theme::getEditTheme()) {
throw new ApplicationException('Unable to find the active theme.');
}
return Page::listInTheme($theme)->lists('fileName', 'fileName');
}
/**
* Ensure each theme has its own CMS page, store it inside a mapping array.
* @return void
*/
public function beforeValidate()
{
if (!$theme = Theme::getEditTheme()) {
throw new ApplicationException('Unable to find the active theme.');
}
$themeMap = $this->getSettingsValue('theme_map', []);
$themeMap[$theme->getDirName()] = $this->getSettingsValue('cms_page');
$this->setSettingsValue('theme_map', $themeMap);
}
/**
* Restore the CMS page found in the mapping array, or disable the
* maintenance mode.
* @return void
*/
public function afterFetch()
{
if (
($theme = Theme::getEditTheme())
&& ($themeMap = array_get($this->value, 'theme_map'))
&& ($cmsPage = array_get($themeMap, $theme->getDirName()))
) {
$this->cms_page = $cmsPage;
}
else {
$this->is_enabled = false;
}
}
/**
* Check if the provided IP is in the allowed IP list.
*
* @param string $ip
* @return bool
*/
public static function isAllowedIp(string $ip): bool
{
return IpUtils::checkIp($ip, Arr::pluck(static::get('allowed_ips', []) ?? [], 'ip'));
}
}

View File

@@ -0,0 +1,309 @@
<?php
namespace Cms\Models;
use Cms\Classes\Theme as CmsTheme;
use Exception;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Lang;
use System\Classes\CombineAssets;
use System\Models\File;
use Winter\Storm\Database\Model;
/**
* Customization data used by a theme
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class ThemeData extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var string The database table used by the model.
*/
public $table = 'cms_theme_data';
/**
* @var array Guarded fields
*/
protected $guarded = [];
/**
* @var array Fillable fields
*/
protected $fillable = [];
/**
* @var array List of attribute names which are json encoded and decoded from the database.
*/
protected $jsonable = ['data'];
/**
* @var array The rules to be applied to the data.
*/
public $rules = [];
/**
* @var array Relations
*/
public $attachOne = [];
/**
* @var ThemeData Cached array of objects
*/
protected static $instances = [];
/**
* @var int The number of minutes the theme data is cached for.
*/
protected static $cacheTtl = 1440;
/**
* Before saving the model, strip dynamic attributes applied from config.
* @return void
*/
public function beforeSave()
{
/*
* Dynamic attributes are stored in the jsonable attribute 'data'.
*/
$staticAttributes = ['id', 'theme', 'data', 'created_at', 'updated_at'];
$dynamicAttributes = array_except($this->getAttributes(), $staticAttributes);
$this->data = $dynamicAttributes;
$this->setRawAttributes(array_only($this->getAttributes(), $staticAttributes));
}
/**
* Clear asset cache after saving to ensure `assetVar` form fields take
* immediate effect.
*/
public function afterSave()
{
static::flushCache($this->theme);
try {
CombineAssets::resetCache();
}
catch (Exception $ex) {
}
}
/**
* Clear the cache after deleting so that the record isn't served from the cache.
*/
public function afterDelete()
{
static::flushCache($this->theme);
}
/**
* Returns the cache key used to store the data for the provided theme directory.
*/
public static function getCacheKey(string $dirName): string
{
return 'cms::theme.data.' . $dirName;
}
/**
* Removes both the persistent and in memory cache of the provided theme directory's data.
*/
public static function flushCache(?string $dirName = null): void
{
if (is_null($dirName)) {
self::$instances = [];
return;
}
Cache::forget(static::getCacheKey($dirName));
unset(self::$instances[$dirName]);
}
/**
* Returns a cached version of this model, based on a Theme object.
* @param $theme Cms\Classes\Theme
* @return self
*/
public static function forTheme($theme)
{
$dirName = $theme->getDirName();
if ($themeData = array_get(self::$instances, $dirName)) {
return $themeData;
}
try {
// The record is cached rather than queried on every request; it is invalidated
// by afterSave() / afterDelete(), which also covers the initial creation below.
$themeData = self::where('theme', $dirName)
->remember(self::$cacheTtl, self::getCacheKey($dirName))
->first() ?: self::create(['theme' => $dirName]);
}
catch (Exception $ex) {
// Database failed
$themeData = new self(['theme' => $dirName]);
}
return self::$instances[$dirName] = $themeData;
}
/**
* After fetching the model, intiialize model relationships based
* on form field definitions.
* @return void
*/
public function afterFetch()
{
$data = (array) $this->data + $this->getDefaultValues();
foreach ($this->getFormFields() as $id => $field) {
if (!isset($field['type'])) {
continue;
}
/*
* Repeater and nested form fields store arrays and must be jsonable.
*/
if (in_array($field['type'], ['repeater', 'nestedform'])) {
$this->jsonable[] = $id;
} elseif ($field['type'] === 'fileupload') {
if (array_get($field, 'multiple', false)) {
$this->attachMany[$id] = File::class;
} else {
$this->attachOne[$id] = File::class;
}
unset($data[$id]);
}
}
/*
* Fill this model with the jsonable attributes kept in 'data'.
*/
$this->setRawAttributes((array) $this->getAttributes() + $data, true);
}
/**
* Before model is validated, set the default values.
* @return void
*/
public function beforeValidate()
{
if (!$this->exists) {
$this->setDefaultValues();
}
}
/**
* Creates relationships for this model based on form field definitions.
*/
public function initFormFields()
{
}
/**
* Sets default values on this model based on form field definitions.
*/
public function setDefaultValues()
{
foreach ($this->getDefaultValues() as $attribute => $value) {
$this->{$attribute} = $value;
}
}
/**
* Gets default values for this model based on form field definitions.
* @return array
*/
public function getDefaultValues()
{
$result = [];
foreach ($this->getFormFields() as $attribute => $field) {
if (($value = array_get($field, 'default')) === null) {
continue;
}
$result[$attribute] = $value;
}
return $result;
}
/**
* Returns all fields defined for this model, based on form field definitions.
* @return array
*/
public function getFormFields()
{
if (!$theme = CmsTheme::load($this->theme)) {
throw new Exception(Lang::get('Unable to find theme with name :name', $this->theme));
}
$config = $theme->getFormConfig();
return array_get($config, 'fields', []) +
array_get($config, 'tabs.fields', []) +
array_get($config, 'secondaryTabs.fields', []);
}
/**
* Returns variables that should be passed to the asset combiner.
* @return array
*/
public function getAssetVariables()
{
$result = [];
foreach ($this->getFormFields() as $attribute => $field) {
if (!$varName = array_get($field, 'assetVar')) {
continue;
}
$result[$varName] = $this->{$attribute};
}
return $result;
}
/**
* Applies asset variables to the combiner filters that support it.
* @return void
*/
public static function applyAssetVariablesToCombinerFilters($filters)
{
$theme = CmsTheme::getActiveTheme();
if (!$theme) {
return;
}
if (!$theme->hasCustomData()) {
return;
}
$assetVars = $theme->getCustomData()->getAssetVariables();
foreach ($filters as $filter) {
if (method_exists($filter, 'setPresets')) {
$filter->setPresets($assetVars);
}
}
}
/**
* Generate a cache key for the combiner, this allows variables to bust the cache.
* @return string
*/
public static function getCombinerCacheKey()
{
$theme = CmsTheme::getActiveTheme();
if (!$theme->hasCustomData()) {
return '';
}
$customData = $theme->getCustomData();
return (string) $customData->updated_at ?: '';
}
}

View File

@@ -0,0 +1,155 @@
<?php namespace Cms\Models;
use File;
use Model;
use Response;
use ApplicationException;
use Winter\Storm\Filesystem\Zip;
use Cms\Classes\Theme as CmsTheme;
use Exception;
/**
* Theme export model
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class ThemeExport extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var string The database table used by the model.
*/
public $table = 'cms_theme_data';
/**
* @var array The rules to be applied to the data.
*/
public $rules = [];
/**
* @var array Guarded fields
*/
protected $guarded = [];
/**
* @var array Fillable fields
*/
protected $fillable = [];
/**
* @var array Make the model's attributes public so behaviors can modify them.
*/
public $attributes = [
'theme' => null,
'themeName' => null,
'dirName' => null,
'folders' => [
'assets' => true,
'pages' => true,
'layouts' => true,
'partials' => true,
'content' => true,
]
];
/**
* Import / Export model classes are helpers and are not to write to the database
*
* @return void
*/
public function save(?array $options = null, $sessionKey = null)
{
throw new ApplicationException(sprintf("The % model is not intended to be saved, please use %s instead", get_class($this), 'ThemeData'));
}
public function getFoldersOptions()
{
return [
'assets' => 'Assets',
'pages' => 'Pages',
'layouts' => 'Layouts',
'partials' => 'Partials',
'content' => 'Content',
];
}
public function setThemeAttribute($theme)
{
if (!$theme instanceof CmsTheme) {
return;
}
$this->attributes['themeName'] = $theme->getConfigValue('name', $theme->getDirName());
$this->attributes['dirName'] = $theme->getDirName();
$this->attributes['theme'] = $theme;
}
public function export($theme, $data = [])
{
$this->theme = $theme;
$this->fill($data);
try {
$themePath = $this->theme->getPath();
$tempPath = temp_path() . '/'.uniqid('oc');
$zipName = uniqid('oc');
$zipPath = temp_path().'/'.$zipName;
if (!File::makeDirectory($tempPath)) {
throw new ApplicationException('Unable to create directory '.$tempPath);
}
if (!File::makeDirectory($metaPath = $tempPath . '/meta')) {
throw new ApplicationException('Unable to create directory '.$metaPath);
}
File::copy($themePath.'/theme.yaml', $tempPath.'/theme.yaml');
File::copyDirectory($themePath.'/meta', $metaPath);
foreach ($this->folders as $folder) {
if (!array_key_exists($folder, $this->getFoldersOptions())) {
continue;
}
File::copyDirectory($themePath.'/'.$folder, $tempPath.'/'.$folder);
}
Zip::make($zipPath, $tempPath);
File::deleteDirectory($tempPath);
}
catch (Exception $ex) {
if (strlen($tempPath) && File::isDirectory($tempPath)) {
File::deleteDirectory($tempPath);
}
if (strlen($zipPath) && File::isFile($zipPath)) {
File::delete($zipPath);
}
throw $ex;
}
return $zipName;
}
public static function download($name, $outputName = null)
{
if (!preg_match('/^oc[0-9a-z]*$/i', $name)) {
throw new ApplicationException('File not found');
}
$zipPath = temp_path() . '/' . $name;
if (!file_exists($zipPath)) {
throw new ApplicationException('File not found');
}
$headers = Response::download($zipPath, $outputName)->headers->all();
$result = Response::make(File::get($zipPath), 200, $headers);
@File::delete($zipPath);
return $result;
}
}

View File

@@ -0,0 +1,200 @@
<?php namespace Cms\Models;
use File;
use Model;
use ApplicationException;
use Winter\Storm\Filesystem\Zip;
use Cms\Classes\Theme as CmsTheme;
use FilesystemIterator;
use Exception;
/**
* Theme import model
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class ThemeImport extends Model
{
use \Winter\Storm\Database\Traits\Validation;
/**
* @var string The database table used by the model.
*/
public $table = 'cms_theme_data';
/**
* @var array The rules to be applied to the data.
*/
public $rules = [];
/**
* @var array Guarded fields
*/
protected $guarded = [];
/**
* @var array Fillable fields
*/
protected $fillable = [];
public $attachOne = [
'uploaded_file' => \System\Models\File::class
];
/**
* @var array Make the model's attributes public so behaviors can modify them.
*/
public $attributes = [
'theme' => null,
'themeName' => null,
'dirName' => null,
'overwrite' => true,
'folders' => [
'assets' => true,
'pages' => true,
'layouts' => true,
'partials' => true,
'content' => true,
]
];
/**
* Import / Export model classes are helpers and are not to write to the database
*
* @return void
*/
public function save(?array $options = null, $sessionKey = null)
{
throw new ApplicationException(sprintf("The % model is not intended to be saved, please use %s instead", get_class($this), 'ThemeData'));
}
public function getFoldersOptions()
{
return [
'assets' => 'Assets',
'pages' => 'Pages',
'layouts' => 'Layouts',
'partials' => 'Partials',
'content' => 'Content',
];
}
public function setThemeAttribute($theme)
{
if (!$theme instanceof CmsTheme) {
return;
}
$this->attributes['themeName'] = $theme->getConfigValue('name', $theme->getDirName());
$this->attributes['dirName'] = $theme->getDirName();
$this->attributes['theme'] = $theme;
}
public function import($theme, $data = [], $sessionKey = null)
{
@set_time_limit(3600);
$this->theme = $theme;
$this->fill($data);
try {
$file = $this->uploaded_file()->withDeferred($sessionKey)->first();
if (!$file) {
throw new ApplicationException('There is no file attached to import!');
}
$themePath = $this->theme->getPath();
$tempPath = temp_path() . '/'.uniqid('oc');
$zipName = uniqid('oc');
$zipPath = temp_path().'/'.$zipName;
File::put($zipPath, $file->getContents());
if (!File::makeDirectory($tempPath)) {
throw new ApplicationException('Unable to create directory '.$tempPath);
}
Zip::extract($zipPath, $tempPath);
if (File::isDirectory($tempPath.'/meta')) {
$this->copyDirectory($tempPath.'/meta', $themePath.'/meta');
}
foreach ($this->folders as $folder) {
if (!array_key_exists($folder, $this->getFoldersOptions())) {
continue;
}
$this->copyDirectory($tempPath.'/'.$folder, $themePath.'/'.$folder);
}
File::deleteDirectory($tempPath);
File::delete($zipPath);
$file->delete();
}
catch (Exception $ex) {
if (!empty($tempPath) && File::isDirectory($tempPath)) {
File::deleteDirectory($tempPath);
}
if (!empty($zipPath) && File::isFile($zipPath)) {
File::delete($zipPath);
}
throw $ex;
}
}
/**
* Helper for copying directories that supports the ability
* to not overwrite existing files. Inherited from File::copyDirectory
*
* @param string $directory
* @param string $destination
* @return bool
*/
protected function copyDirectory($directory, $destination)
{
// Preference is to overwrite existing files
if ($this->overwrite) {
return File::copyDirectory($directory, $destination);
}
if (!File::isDirectory($directory)) {
return false;
}
$options = FilesystemIterator::SKIP_DOTS;
if (!File::isDirectory($destination)) {
File::makeDirectory($destination, 0777, true);
}
$items = new FilesystemIterator($directory, $options);
foreach ($items as $item) {
$target = $destination.'/'.$item->getBasename();
if ($item->isDir()) {
$path = $item->getPathname();
if (!$this->copyDirectory($path, $target)) {
return false;
}
}
else {
// Do not overwrite existing files
if (File::isFile($target)) {
continue;
}
if (!File::copy($item->getPathname(), $target)) {
return false;
}
}
}
return true;
}
}

View File

@@ -0,0 +1,129 @@
<?php namespace Cms\Models;
use BackendAuth;
use Cms\Classes\Theme;
use Exception;
use Model;
use System\Models\LogSetting;
use Winter\Storm\Halcyon\Model as HalcyonModel;
/**
* Model for changes made to the theme
*
* @package winter\wn-cms-module
* @author Alexey Bobkov, Samuel Georges
*/
class ThemeLog extends Model
{
const TYPE_CREATE = 'create';
const TYPE_UPDATE = 'update';
const TYPE_DELETE = 'delete';
/**
* @var string The database table used by the model.
*/
protected $table = 'cms_theme_logs';
/**
* @var array Relations
*/
public $belongsTo = [
'user' => \Backend\Models\User::class
];
protected $themeCache;
/**
* Adds observers to the model for logging purposes.
*/
public static function bindEventsToModel(HalcyonModel $template)
{
$template->bindEvent('model.beforeDelete', function () use ($template) {
self::add($template, self::TYPE_DELETE);
});
$template->bindEvent('model.beforeSave', function () use ($template) {
self::add($template, $template->exists ? self::TYPE_UPDATE : self::TYPE_CREATE);
});
}
/**
* Creates a log record
*/
public static function add(HalcyonModel $template, ?string $type = null): ?self
{
if (!LogSetting::hasDatabaseTable()) {
return null;
}
if (!LogSetting::get('log_theme')) {
return null;
}
if (!$type) {
$type = self::TYPE_UPDATE;
}
$isDelete = $type === self::TYPE_DELETE;
$dirName = $template->getObjectTypeDirName();
$templateName = $template->fileName;
$oldTemplateName = $template->getOriginal('fileName');
$newContent = $template->toCompiled();
$oldContent = $template->getOriginal('content');
if ($newContent === $oldContent && $templateName === $oldTemplateName && !$isDelete) {
return null;
}
$record = new self;
$record->type = $type;
$record->theme = Theme::getEditThemeCode();
$record->template = $isDelete ? '' : $dirName.'/'.$templateName;
$record->old_template = $oldTemplateName ? $dirName.'/'.$oldTemplateName : '';
$record->content = $isDelete ? '' : $newContent;
$record->old_content = $oldContent;
if ($user = BackendAuth::getRealUser()) {
$record->user_id = $user->id;
}
try {
$record->save();
} catch (Exception $ex) {
}
return $record;
}
public function getThemeNameAttribute()
{
$code = $this->theme;
if (!isset($this->themeCache[$code])) {
$this->themeCache[$code] = Theme::load($code);
}
$theme = $this->themeCache[$code];
return $theme->getConfigValue('name', $theme->getDirName());
}
public function getTypeOptions()
{
return [
self::TYPE_CREATE => 'cms::lang.theme_log.type_create',
self::TYPE_UPDATE => 'cms::lang.theme_log.type_update',
self::TYPE_DELETE => 'cms::lang.theme_log.type_delete'
];
}
public function getAnyTemplateAttribute()
{
return $this->template ?: $this->old_template;
}
public function getTypeNameAttribute()
{
return array_get($this->getTypeOptions(), $this->type);
}
}

View File

@@ -0,0 +1,4 @@
<p>
<?= e(trans('cms::lang.maintenance.hint')) ?>
</p>

View File

@@ -0,0 +1,35 @@
# ===================================
# Field Definitions
# ===================================
fields:
hint:
type: hint
path: ~/modules/cms/models/maintenancesetting/_hint.php
is_enabled:
label: cms::lang.maintenance.is_enabled
comment: cms::lang.maintenance.is_enabled_comment
type: checkbox
cms_page:
type: dropdown
cssClass: checkbox-align
allowed_ips:
label: cms::lang.maintenance.allowed_ips.name
type: repeater
commentAbove: cms::lang.maintenance.allowed_ips.description
prompt: cms::lang.maintenance.allowed_ips.prompt
cssClass: checkbox-align
form:
fields:
ip:
label: cms::lang.maintenance.allowed_ips.ip
type: text
span: left
label:
label: cms::lang.maintenance.allowed_ips.label
type: text
span: right

View File

@@ -0,0 +1,14 @@
# ===================================
# Field Definitions
# ===================================
fields:
themeName:
label: cms::lang.theme.theme_label
disabled: true
folders:
label: cms::lang.theme.export_folders_label
commentAbove: cms::lang.theme.export_folders_comment
type: checkboxlist

View File

@@ -0,0 +1,25 @@
# ===================================
# Field Definitions
# ===================================
fields:
themeName:
label: cms::lang.theme.theme_label
disabled: true
uploaded_file:
label: cms::lang.theme.import_uploaded_file
type: fileupload
mode: file
fileTypes: zip
overwrite:
label: cms::lang.theme.import_overwrite_label
comment: cms::lang.theme.import_overwrite_comment
type: checkbox
folders:
label: cms::lang.theme.import_folders_label
commentAbove: cms::lang.theme.import_folders_comment
type: checkboxlist

View File

@@ -0,0 +1,49 @@
# ===================================
# Column Definitions
# ===================================
columns:
id:
label: cms::lang.theme_log.id
searchable: yes
invisible: true
width: 75px
created_at:
label: cms::lang.theme_log.created_at
searchable: yes
width: 160px
type: timetense
type:
label: cms::lang.theme_log.type
invisible: true
any_template:
label: cms::lang.theme_log.template
searchable: false
sortable: false
template:
label: cms::lang.theme_log.new_template
searchable: true
invisible: true
old_template:
label: cms::lang.theme_log.old_template
searchable: true
invisible: true
user:
label: cms::lang.theme_log.user
relation: user
select: concat(first_name, ' ', last_name)
theme_name:
label: cms::lang.theme_log.theme_name
sortable: false
theme:
label: cms::lang.theme_log.theme_code
searchable: true
invisible: true

View File

@@ -0,0 +1,36 @@
# ===================================
# Field Definitions
# ===================================
tabs:
fields:
diff_template:
tab: cms::lang.theme_log.diff
type: partial
path: field_diff_template
diff_content:
tab: cms::lang.theme_log.diff
type: partial
path: field_diff_content
template:
tab: cms::lang.theme_log.new_value
type: partial
path: field_template
content:
tab: cms::lang.theme_log.new_value
type: partial
path: field_content
old_template:
tab: cms::lang.theme_log.old_value
type: partial
path: field_template
old_content:
tab: cms::lang.theme_log.old_value
type: partial
path: field_content