F-002 fix: Remove secrets and externalize config

This commit is contained in:
rikrdo
2026-05-25 08:00:05 +02:00
parent d3a558352d
commit 3d41579ad3
58 changed files with 1192807 additions and 52 deletions

View File

@@ -0,0 +1,13 @@
# Legacy PHP module
Important paths:
- `bootstrap.php` — shared config loader
- `config/local.example.php` — copy template
- `config/local.php` — real local values, ignored by git
- `db/conn.php` — shared DB connection helper
- `worker_bulk.php` — CLI worker
Setup:
1. Review `config/README.md`.
2. Fill `config/local.php` with local values.
3. Import `project/sql/db-25052026.sql` into local MariaDB if needed.

View File

@@ -0,0 +1,140 @@
<?php
if (defined('LEGACY_MODULE_BOOTSTRAPPED')) {
return;
}
define('LEGACY_MODULE_BOOTSTRAPPED', true);
define('LEGACY_MODULE_ROOT', __DIR__);
define('LEGACY_MODULE_CONFIG_DIR', LEGACY_MODULE_ROOT . '/config');
define('LEGACY_MODULE_LOCAL_CONFIG', LEGACY_MODULE_CONFIG_DIR . '/local.php');
define('LEGACY_MODULE_EXAMPLE_CONFIG', LEGACY_MODULE_CONFIG_DIR . '/local.example.php');
function legacy_default_config() {
return [
'db' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => '',
'user' => '',
'password' => '',
'charset' => 'utf8',
],
'openai' => [
'api_key' => '',
'model' => 'gpt-4o-mini',
'endpoint' => 'https://api.openai.com/v1/chat/completions',
],
'store' => [
'name' => 'Natural - Mercado de Vida',
'language_es' => 4,
'language_en' => 1,
'image_base_url' => 'https://example.local/image/',
'product_base_url' => 'https://example.local/index.php?route=product/product&product_id=',
],
'routes' => [
'login_url' => '../login.php',
'success_url' => 'https://example.local/producto-nuevo/success.php',
],
'security' => [
'form_password_hash' => '',
],
'paths' => [
'log_dir' => LEGACY_MODULE_ROOT . '/logs',
'worker_log' => LEGACY_MODULE_ROOT . '/logs/worker.log',
'prompt_en' => LEGACY_MODULE_ROOT . '/inc/prompt_en.md',
'prompt_es' => LEGACY_MODULE_ROOT . '/inc/prompt_es.md',
],
'worker' => [
'batch_size' => 20,
'min_html_length' => 500,
],
];
}
function legacy_deep_merge(array $base, array $override) {
foreach ($override as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
$base[$key] = legacy_deep_merge($base[$key], $value);
continue;
}
$base[$key] = $value;
}
return $base;
}
function legacy_config_file_data($path) {
if (!is_file($path)) {
return [];
}
$data = require $path;
return is_array($data) ? $data : [];
}
function legacy_normalize_config(array $config) {
foreach (['image_base_url', 'product_base_url'] as $key) {
if (isset($config['store'][$key])) {
$config['store'][$key] = trim((string)$config['store'][$key]);
}
}
if (isset($config['routes']['login_url'])) {
$config['routes']['login_url'] = trim((string)$config['routes']['login_url']);
}
if (isset($config['routes']['success_url'])) {
$config['routes']['success_url'] = trim((string)$config['routes']['success_url']);
}
return $config;
}
$legacy_config = legacy_default_config();
$legacy_config_source = LEGACY_MODULE_EXAMPLE_CONFIG;
if (is_file(LEGACY_MODULE_LOCAL_CONFIG)) {
$legacy_config = legacy_deep_merge($legacy_config, legacy_config_file_data(LEGACY_MODULE_LOCAL_CONFIG));
$legacy_config_source = LEGACY_MODULE_LOCAL_CONFIG;
} elseif (is_file(LEGACY_MODULE_EXAMPLE_CONFIG)) {
$legacy_config = legacy_deep_merge($legacy_config, legacy_config_file_data(LEGACY_MODULE_EXAMPLE_CONFIG));
}
$legacy_config = legacy_normalize_config($legacy_config);
function legacy_config_all() {
global $legacy_config;
return $legacy_config;
}
function legacy_config($path, $default = null) {
$value = legacy_config_all();
foreach (explode('.', $path) as $segment) {
if (!is_array($value) || !array_key_exists($segment, $value)) {
return $default;
}
$value = $value[$segment];
}
return $value;
}
function legacy_config_source() {
global $legacy_config_source;
return $legacy_config_source;
}
function legacy_new_mysqli() {
$host = legacy_config('db.host', '127.0.0.1');
$user = legacy_config('db.user', '');
$password = legacy_config('db.password', '');
$database = legacy_config('db.database', '');
$port = (int) legacy_config('db.port', 3306);
$charset = legacy_config('db.charset', 'utf8');
$db = new mysqli($host, $user, $password, $database, $port);
if (!$db->connect_errno && $charset) {
$db->set_charset($charset);
}
return $db;
}

View File

@@ -0,0 +1,17 @@
# Local config setup
1. Copy `local.example.php` to `local.php`.
2. Fill real local DB, OpenAI, and URL values in `local.php`.
3. Keep `local.php` out of git.
Config keys used by the legacy module:
- `db.*`
- `openai.*`
- `store.*`
- `routes.*`
- `security.form_password_hash`
- `worker.*`
The module loads:
- real local values from `config/local.php`
- safe fallback values from `config/local.example.php`

View File

@@ -0,0 +1,35 @@
<?php
return [
'db' => [
'host' => '127.0.0.1',
'port' => 3306,
'database' => 'CHANGE_ME_DB_NAME',
'user' => 'CHANGE_ME_DB_USER',
'password' => 'CHANGE_ME_DB_PASSWORD',
'charset' => 'utf8',
],
'openai' => [
'api_key' => 'CHANGE_ME_OPENAI_API_KEY',
'model' => 'gpt-4o-mini',
'endpoint' => 'https://api.openai.com/v1/chat/completions',
],
'store' => [
'name' => 'Natural - Mercado de Vida',
'language_es' => 4,
'language_en' => 1,
'image_base_url' => 'https://example.local/image/',
'product_base_url' => 'https://example.local/index.php?route=product/product&product_id=',
],
'routes' => [
'login_url' => '../login.php',
'success_url' => 'https://example.local/producto-nuevo/success.php',
],
'security' => [
'form_password_hash' => 'CHANGE_ME_FORM_PASSWORD_HASH',
],
'worker' => [
'batch_size' => 20,
'min_html_length' => 500,
],
];

View File

@@ -0,0 +1,396 @@
option:disabled {
font-weight: bolder;
color: #FF5722;
}
#atrib_zone, #edit_zone {
border: #ccc 3px dashed;
padding: 15px 15px 0px 15px;
}
.ck-editor__editable_inline {
min-height: 250px;
}
input[name=modelo], input[name=url] {
pointer-events: none;
background: whitesmoke;
color: #FF5722;
font-weight: bolder;
}
#imgPreview {
text-align: center;
padding-bottom: 7px;
}
.infoText {
font-size: small;
font-weight: bolder;
color: #FF5722;
}
input[type="file"] {
display: none;
}
.uploadImg {
border: 1px solid #ccc;
/*display: inline-block;*/
padding: 20px 12px 6px 12px;
cursor: pointer;
text-align: center;
height: 37px;
border-radius: 4px;
}
#imgElement {
max-width: 170px;
max-height: 120px;
}
#crearProducto {
height: 110px;
}
#loading {
display: none;
}
#ia_link {
/* display: none; */
background-color: #FFC107;
border-color: #FFC107;
}
/* Definimos la animación de parpadeo */
@keyframes blink {
0% { opacity: 1; }
50% { opacity: 0.7; }
100% { opacity: 1; }
}
/* Aplicamos la animación al elemento */
#ia_link {
animation: blink 2s ease-in-out infinite;
transition: opacity 0.5s ease-in-out;
}
/* Agregamos un efecto de transición suave */
#ia_link:hover {
opacity: 0.8;
}
/* ============================================================
Natural - Mercado de Vida
Panel de administración SEO y descripciones
Complemento para Skeleton Boilerplate
============================================================ */
/* ---------- Variables ---------- */
:root {
--color-primary: #0074D9;
--color-success: #28a745;
--color-danger: #dc3545;
--color-warning: #ffae00;
--color-light: #f4f4f4;
--color-muted: #666;
}
/* ---------- Contenedores ---------- */
.container {
max-width: 1100px;
margin: 0 auto;
padding: 30px 0 50px;
}
/* ---------- Tablas ---------- */
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background: #fff;
box-shadow: 0 0 3px rgba(0,0,0,0.05);
border: 1px solid #ddd;
}
th, td {
border: 1px solid #ddd;
padding: 10px 10px;
vertical-align: top;
}
th {
background: var(--color-light);
text-align: left;
color: #222;
}
tr:hover td {
background: #f9f9f9;
}
/* ---------- Imágenes ---------- */
td img {
width: 140px;
border-radius: 6px;
display: block;
margin: 0 auto 8px;
}
/* ---------- Textos de idiomas ---------- */
.lang-title {
font-weight: bold;
color: var(--color-primary);
margin-bottom: 4px;
}
.lang-section {
line-height: 1.5em;
font-size: 1rem;
text-align: justify;
}
/* ---------- Badges ---------- */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: .85rem;
color: #fff;
margin-left: 6px;
}
.badge.ok {
background: var(--color-success);
}
.badge.miss {
background: var(--color-danger);
}
.badge.warn {
background: var(--color-warning);
color: #000;
}
/* ---------- Botones ---------- */
.button-primary,
button.button-primary {
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all .2s ease;
}
.button-primary:hover {
opacity: 0.9;
}
/* Botón de verificación manual */
.toggle-btn {
background: var(--color-warning);
color: #000;
border: none;
border-radius: 5px;
padding: 5px 12px;
cursor: pointer;
font-size: 0.9rem;
margin-top: 6px;
transition: all .2s ease-in-out;
}
.toggle-btn.active {
background: var(--color-success);
color: #fff;
}
/* Toolbar superior */
.toolbar {
margin: 10px 0 15px;
}
.toolbar button {
margin-right: 8px;
}
/* ---------- Caja de ID y estado ---------- */
.idbox {
font-size: .9rem;
color: #333;
text-align: center;
}
.idbox small {
color: var(--color-muted);
}
.filter-bar {
margin-bottom: 20px;
}
.filter-actions a {
display: inline-block;
margin-top: 8px;
}
.alert-box {
background: #f5f8fb;
border: 1px solid #d9e3ec;
border-radius: 6px;
padding: 18px;
}
.table-description {
max-height: 120px;
overflow: auto;
}
.log-panel {
margin-top: 20px;
background: #f9f9f9;
border-radius: 8px;
border: 1px solid #e0e0e0;
}
.log-panel__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #e0e0e0;
}
.log-panel__status {
font-size: 0.95rem;
color: #666;
}
.log-panel__body {
margin: 0;
padding: 14px;
max-height: 360px;
overflow: auto;
font-size: 1.3rem;
line-height: 1.45;
background: #111;
color: #f1f1f1;
}
.pagination-row {
margin-top: 18px;
text-align: center;
}
.pagination-row .button {
margin: 0 4px;
}
.pagination-row .button.disabled {
pointer-events: none;
opacity: .4;
}
.pagination-row .pagination-meta {
margin: 0 12px;
color: var(--color-muted);
}
/* ---------- Loader ---------- */
.loader-container {
display: none;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 30px;
}
.loader {
border: 5px solid #eee;
border-top: 5px solid var(--color-primary);
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
margin-top: 10px;
font-weight: bold;
color: #333;
animation: fadeText 1.5s infinite;
}
@keyframes fadeText {
0%,100% { opacity: 0.2; }
50% { opacity: 1; }
}
/* ---------- Paginación ---------- */
.pagination {
text-align: center;
margin-top: 25px;
}
.pagination a {
display: inline-block;
padding: 6px 12px;
margin: 0 4px;
text-decoration: none;
border-radius: 4px;
background: var(--color-primary);
color: #fff;
}
.pagination a.disabled {
background: #ccc;
pointer-events: none;
}
.pagination span {
display: inline-block;
margin: 0 10px;
font-weight: bold;
}
/* ---------- Responsive ---------- */
@media (max-width: 768px) {
td img { width: 100px; }
table, th, td { font-size: 1.3rem; }
.toolbar { text-align: center; }
.lang-section { font-size: 1.3rem;
text-align: justify;}
}
.last {
margin: 10px 0px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
margin-top: 26px;
}
.checkbox-label .label-body {
margin: 0;
}
/* Alineación vertical suave y estética Skeleton */
#missing {
position: relative;
top: 2px;
margin-right: 5px;
}
label[for="missing"] {
font-weight: 500;
cursor: pointer;
display: inline-block;
color: #555;
}

View File

@@ -0,0 +1,39 @@
/* The Modal (background) */
.modal {
display: none; /* Hidden by default */
position: fixed; /* Stay in place */
z-index: 1; /* Sit on top */
padding-top: 100px; /* Location of the box */
left: 0;
top: 0;
width: 100%; /* Full width */
height: 100%; /* Full height */
overflow: auto; /* Enable scroll if needed */
background-color: rgb(0,0,0); /* Fallback color */
background-color: rgba(0,0,0,0.4); /* Black w/ opacity */
}
/* Modal Content */
.modal-content {
background-color: #fefefe;
margin: auto;
padding: 20px;
border: 1px solid #888;
max-width: 750px;
width: 70%;
}
/* The Close Button */
.close {
color: #aaaaaa;
float: right;
font-size: 28px;
font-weight: bold;
}
.close:hover,
.close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}

427
project/web/index/new/css/normalize.css vendored Normal file
View File

@@ -0,0 +1,427 @@
/*! normalize.css v3.0.2 | MIT License | git.io/normalize */
/**
* 1. Set default font family to sans-serif.
* 2. Prevent iOS text size adjust after orientation change, without disabling
* user zoom.
*/
html {
font-family: sans-serif; /* 1 */
-ms-text-size-adjust: 100%; /* 2 */
-webkit-text-size-adjust: 100%; /* 2 */
}
/**
* Remove default margin.
*/
body {
margin: 0;
}
/* HTML5 display definitions
========================================================================== */
/**
* Correct `block` display not defined for any HTML5 element in IE 8/9.
* Correct `block` display not defined for `details` or `summary` in IE 10/11
* and Firefox.
* Correct `block` display not defined for `main` in IE 11.
*/
article,
aside,
details,
figcaption,
figure,
footer,
header,
hgroup,
main,
menu,
nav,
section,
summary {
display: block;
}
/**
* 1. Correct `inline-block` display not defined in IE 8/9.
* 2. Normalize vertical alignment of `progress` in Chrome, Firefox, and Opera.
*/
audio,
canvas,
progress,
video {
display: inline-block; /* 1 */
vertical-align: baseline; /* 2 */
}
/**
* Prevent modern browsers from displaying `audio` without controls.
* Remove excess height in iOS 5 devices.
*/
audio:not([controls]) {
display: none;
height: 0;
}
/**
* Address `[hidden]` styling not present in IE 8/9/10.
* Hide the `template` element in IE 8/9/11, Safari, and Firefox < 22.
*/
[hidden],
template {
display: none;
}
/* Links
========================================================================== */
/**
* Remove the gray background color from active links in IE 10.
*/
a {
background-color: transparent;
}
/**
* Improve readability when focused and also mouse hovered in all browsers.
*/
a:active,
a:hover {
outline: 0;
}
/* Text-level semantics
========================================================================== */
/**
* Address styling not present in IE 8/9/10/11, Safari, and Chrome.
*/
abbr[title] {
border-bottom: 1px dotted;
}
/**
* Address style set to `bolder` in Firefox 4+, Safari, and Chrome.
*/
b,
strong {
font-weight: bold;
}
/**
* Address styling not present in Safari and Chrome.
*/
dfn {
font-style: italic;
}
/**
* Address variable `h1` font-size and margin within `section` and `article`
* contexts in Firefox 4+, Safari, and Chrome.
*/
h1 {
font-size: 2em;
margin: 0.67em 0;
}
/**
* Address styling not present in IE 8/9.
*/
mark {
background: #ff0;
color: #000;
}
/**
* Address inconsistent and variable font size in all browsers.
*/
small {
font-size: 80%;
}
/**
* Prevent `sub` and `sup` affecting `line-height` in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sup {
top: -0.5em;
}
sub {
bottom: -0.25em;
}
/* Embedded content
========================================================================== */
/**
* Remove border when inside `a` element in IE 8/9/10.
*/
img {
border: 0;
}
/**
* Correct overflow not hidden in IE 9/10/11.
*/
svg:not(:root) {
overflow: hidden;
}
/* Grouping content
========================================================================== */
/**
* Address margin not present in IE 8/9 and Safari.
*/
figure {
margin: 1em 40px;
}
/**
* Address differences between Firefox and other browsers.
*/
hr {
-moz-box-sizing: content-box;
box-sizing: content-box;
height: 0;
}
/**
* Contain overflow in all browsers.
*/
pre {
overflow: auto;
}
/**
* Address odd `em`-unit font size rendering in all browsers.
*/
code,
kbd,
pre,
samp {
font-family: monospace, monospace;
font-size: 1em;
}
/* Forms
========================================================================== */
/**
* Known limitation: by default, Chrome and Safari on OS X allow very limited
* styling of `select`, unless a `border` property is set.
*/
/**
* 1. Correct color not being inherited.
* Known issue: affects color of disabled elements.
* 2. Correct font properties not being inherited.
* 3. Address margins set differently in Firefox 4+, Safari, and Chrome.
*/
button,
input,
optgroup,
select,
textarea {
color: inherit; /* 1 */
font: inherit; /* 2 */
margin: 0; /* 3 */
}
/**
* Address `overflow` set to `hidden` in IE 8/9/10/11.
*/
button {
overflow: visible;
}
/**
* Address inconsistent `text-transform` inheritance for `button` and `select`.
* All other form control elements do not inherit `text-transform` values.
* Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
* Correct `select` style inheritance in Firefox.
*/
button,
select {
text-transform: none;
}
/**
* 1. Avoid the WebKit bug in Android 4.0.* where (2) destroys native `audio`
* and `video` controls.
* 2. Correct inability to style clickable `input` types in iOS.
* 3. Improve usability and consistency of cursor style between image-type
* `input` and others.
*/
button,
html input[type="button"], /* 1 */
input[type="reset"],
input[type="submit"] {
-webkit-appearance: button; /* 2 */
cursor: pointer; /* 3 */
}
/**
* Re-set default cursor for disabled elements.
*/
button[disabled],
html input[disabled] {
cursor: default;
}
/**
* Remove inner padding and border in Firefox 4+.
*/
button::-moz-focus-inner,
input::-moz-focus-inner {
border: 0;
padding: 0;
}
/**
* Address Firefox 4+ setting `line-height` on `input` using `!important` in
* the UA stylesheet.
*/
input {
line-height: normal;
}
/**
* It's recommended that you don't attempt to style these elements.
* Firefox's implementation doesn't respect box-sizing, padding, or width.
*
* 1. Address box sizing set to `content-box` in IE 8/9/10.
* 2. Remove excess padding in IE 8/9/10.
*/
input[type="checkbox"],
input[type="radio"] {
box-sizing: border-box; /* 1 */
padding: 0; /* 2 */
}
/**
* Fix the cursor style for Chrome's increment/decrement buttons. For certain
* `font-size` values of the `input`, it causes the cursor style of the
* decrement button to change from `default` to `text`.
*/
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
height: auto;
}
/**
* 1. Address `appearance` set to `searchfield` in Safari and Chrome.
* 2. Address `box-sizing` set to `border-box` in Safari and Chrome
* (include `-moz` to future-proof).
*/
input[type="search"] {
-webkit-appearance: textfield; /* 1 */
-moz-box-sizing: content-box;
-webkit-box-sizing: content-box; /* 2 */
box-sizing: content-box;
}
/**
* Remove inner padding and search cancel button in Safari and Chrome on OS X.
* Safari (but not Chrome) clips the cancel button when the search input has
* padding (and `textfield` appearance).
*/
input[type="search"]::-webkit-search-cancel-button,
input[type="search"]::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
* Define consistent border, margin, and padding.
*/
fieldset {
border: 1px solid #c0c0c0;
margin: 0 2px;
padding: 0.35em 0.625em 0.75em;
}
/**
* 1. Correct `color` not being inherited in IE 8/9/10/11.
* 2. Remove padding so people aren't caught out if they zero out fieldsets.
*/
legend {
border: 0; /* 1 */
padding: 0; /* 2 */
}
/**
* Remove default vertical scrollbar in IE 8/9/10/11.
*/
textarea {
overflow: auto;
}
/**
* Don't inherit the `font-weight` (applied by a rule above).
* NOTE: the default cannot safely be changed in Chrome and Safari on OS X.
*/
optgroup {
font-weight: bold;
}
/* Tables
========================================================================== */
/**
* Remove most spacing between table cells.
*/
table {
border-collapse: collapse;
border-spacing: 0;
}
td,
th {
padding: 0;
}

427
project/web/index/new/css/skeleton.css vendored Normal file
View File

@@ -0,0 +1,427 @@
/*
* Skeleton V2.0.4
* Copyright 2014, Dave Gamache
* www.getskeleton.com
* Free to use under the MIT license.
* http://www.opensource.org/licenses/mit-license.php
* 12/29/2014
*/
/* Table of contents
- Grid
- Base Styles
- Typography
- Links
- Buttons
- Forms
- Lists
- Code
- Tables
- Spacing
- Utilities
- Clearing
- Media Queries
*/
/* Grid
*/
.container {
position: relative;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
box-sizing: border-box; }
.column,
.columns {
width: 100%;
float: left;
box-sizing: border-box; }
/* For devices larger than 400px */
@media (min-width: 400px) {
.container {
width: 85%;
padding: 0; }
}
/* For devices larger than 550px */
@media (min-width: 550px) {
.container {
width: 80%; }
.column,
.columns {
margin-left: 4%; }
.column:first-child,
.columns:first-child {
margin-left: 0; }
.one.column,
.one.columns { width: 4.66666666667%; }
.two.columns { width: 13.3333333333%; }
.three.columns { width: 22%; }
.four.columns { width: 30.6666666667%; }
.five.columns { width: 39.3333333333%; }
.six.columns { width: 48%; }
.seven.columns { width: 56.6666666667%; }
.eight.columns { width: 65.3333333333%; }
.nine.columns { width: 74.0%; }
.ten.columns { width: 82.6666666667%; }
.eleven.columns { width: 91.3333333333%; }
.twelve.columns { width: 100%; margin-left: 0; }
.one-third.column { width: 30.6666666667%; }
.two-thirds.column { width: 65.3333333333%; }
.one-half.column { width: 48%; }
/* Offsets */
.offset-by-one.column,
.offset-by-one.columns { margin-left: 8.66666666667%; }
.offset-by-two.column,
.offset-by-two.columns { margin-left: 17.3333333333%; }
.offset-by-three.column,
.offset-by-three.columns { margin-left: 26%; }
.offset-by-four.column,
.offset-by-four.columns { margin-left: 34.6666666667%; }
.offset-by-five.column,
.offset-by-five.columns { margin-left: 43.3333333333%; }
.offset-by-six.column,
.offset-by-six.columns { margin-left: 52%; }
.offset-by-seven.column,
.offset-by-seven.columns { margin-left: 60.6666666667%; }
.offset-by-eight.column,
.offset-by-eight.columns { margin-left: 69.3333333333%; }
.offset-by-nine.column,
.offset-by-nine.columns { margin-left: 78.0%; }
.offset-by-ten.column,
.offset-by-ten.columns { margin-left: 86.6666666667%; }
.offset-by-eleven.column,
.offset-by-eleven.columns { margin-left: 95.3333333333%; }
.offset-by-one-third.column,
.offset-by-one-third.columns { margin-left: 34.6666666667%; }
.offset-by-two-thirds.column,
.offset-by-two-thirds.columns { margin-left: 69.3333333333%; }
.offset-by-one-half.column,
.offset-by-one-half.columns { margin-left: 52%; }
}
/* Base Styles
*/
/* NOTE
html is set to 62.5% so that all the REM measurements throughout Skeleton
are based on 10px sizing. So basically 1.5rem = 15px :) */
html {
font-size: 62.5%; }
body {
font-size: 1.5em; /* currently ems cause chrome bug misinterpreting rems on body element */
line-height: 1.6;
font-weight: 400;
font-family: "Raleway", "HelveticaNeue", "Helvetica Neue", Helvetica, Arial, sans-serif;
color: #222; }
/* Typography
*/
h1, h2, h3, h4, h5, h6 {
margin-top: 0;
margin-bottom: 2rem;
font-weight: 300; }
h1 { font-size: 4.0rem; line-height: 1.2; letter-spacing: -.1rem;}
h2 { font-size: 3.6rem; line-height: 1.25; letter-spacing: -.1rem; }
h3 { font-size: 3.0rem; line-height: 1.3; letter-spacing: -.1rem; }
h4 { font-size: 2.4rem; line-height: 1.35; letter-spacing: -.08rem; }
h5 { font-size: 1.8rem; line-height: 1.5; letter-spacing: -.05rem; }
h6 { font-size: 1.5rem; line-height: 1.6; letter-spacing: 0; }
/* Larger than phablet */
@media (min-width: 550px) {
h1 { font-size: 5.0rem; }
h2 { font-size: 4.2rem; }
h3 { font-size: 3.6rem; }
h4 { font-size: 3.0rem; }
h5 { font-size: 2.4rem; }
h6 { font-size: 1.5rem; }
}
p {
margin-top: 0; }
/* Links
*/
a {
color: #1EAEDB; }
a:hover {
color: #0FA0CE; }
/* Buttons
*/
.button,
button,
input[type="submit"],
input[type="reset"],
input[type="button"] {
display: inline-block;
height: 38px;
padding: 0 30px;
color: #555;
text-align: center;
font-size: 11px;
font-weight: 600;
line-height: 38px;
letter-spacing: .1rem;
text-transform: uppercase;
text-decoration: none;
white-space: nowrap;
background-color: transparent;
border-radius: 4px;
border: 1px solid #bbb;
cursor: pointer;
box-sizing: border-box; }
.button:hover,
button:hover,
input[type="submit"]:hover,
input[type="reset"]:hover,
input[type="button"]:hover,
.button:focus,
button:focus,
input[type="submit"]:focus,
input[type="reset"]:focus,
input[type="button"]:focus {
color: #333;
border-color: #888;
outline: 0; }
.button.button-primary,
button.button-primary,
input[type="submit"].button-primary,
input[type="reset"].button-primary,
input[type="button"].button-primary {
color: #FFF;
background-color: #70ad47;
border-color: #70ad47; }
.button.button-primary:hover,
button.button-primary:hover,
input[type="submit"].button-primary:hover,
input[type="reset"].button-primary:hover,
input[type="button"].button-primary:hover,
.button.button-primary:focus,
button.button-primary:focus,
input[type="submit"].button-primary:focus,
input[type="reset"].button-primary:focus,
input[type="button"].button-primary:focus {
color: #FFF;
background-color: #70ad47de;
border-color: #70ad47de; }
/* Forms
*/
input[type="email"],
input[type="number"],
input[type="date"],
input[type="search"],
input[type="text"],
input[type="color"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="file"],
textarea,
select {
height: 38px;
padding: 6px 10px; /* The 6px vertically centers text on FF, ignored by Webkit */
background-color: #fff;
border: 1px solid #D1D1D1;
border-radius: 4px;
box-shadow: none;
box-sizing: border-box; }
/* Removes awkward default styles on some inputs for iOS */
input[type="email"],
input[type="number"],
input[type="date"],
input[type="search"],
input[type="text"],
input[type="color"],
input[type="tel"],
input[type="url"],
input[type="password"],
input[type="file"],
textarea {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none; }
textarea {
min-height: 65px;
padding-top: 6px;
padding-bottom: 6px; }
input[type="email"]:focus,
input[type="number"]:focus,
input[type="date"]:focus,
input[type="search"]:focus,
input[type="text"]:focus,
input[type="color"]:focus,
input[type="tel"]:focus,
input[type="url"]:focus,
input[type="password"]:focus,
input[type="file"]:focus,
textarea:focus,
select:focus {
border: 1px solid #70ad47;
outline: 0; }
label,
legend {
display: block;
margin-bottom: .5rem;
font-weight: 600; }
fieldset {
padding: 0;
border-width: 0; }
input[type="checkbox"],
input[type="radio"] {
display: inline; }
label > .label-body {
display: inline-block;
margin-left: .5rem;
font-weight: normal; }
/* Lists
*/
ul {
list-style: circle inside; }
ol {
list-style: decimal inside; }
ol, ul {
padding-left: 0;
margin-top: 0; }
ul ul,
ul ol,
ol ol,
ol ul {
margin: 1.5rem 0 1.5rem 3rem;
font-size: 90%; }
li {
margin-bottom: 1rem; }
/* Code
*/
code {
padding: .2rem .5rem;
margin: 0 .2rem;
font-size: 90%;
white-space: nowrap;
background: #F1F1F1;
border: 1px solid #E1E1E1;
border-radius: 4px; }
pre > code {
display: block;
padding: 1rem 1.5rem;
white-space: pre; }
/* Tables
*/
th,
td {
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid #E1E1E1; }
th:first-child,
td:first-child {
padding-left: 0; }
th:last-child,
td:last-child {
padding-right: 0; }
/* Spacing
*/
button,
.button {
margin-bottom: 1rem; }
input,
textarea,
select,
fieldset {
margin-bottom: 1.5rem; }
pre,
blockquote,
dl,
figure,
table,
p,
ul,
ol,
form {
margin-bottom: 2.5rem; }
/* Utilities
*/
.u-full-width {
width: 100%;
box-sizing: border-box; }
.u-max-full-width {
max-width: 100%;
box-sizing: border-box; }
.u-pull-right {
float: right; }
.u-pull-left {
float: left; }
/* Misc
*/
hr {
margin-top: 3rem;
margin-bottom: 3.5rem;
border-width: 0;
border-top: 1px solid #E1E1E1; }
/* Clearing
*/
/* Self Clearing Goodness */
.container:after,
.row:after,
.u-cf {
content: "";
display: table;
clear: both; }
/* Media Queries
*/
/*
Note: The best way to structure the use of media queries is to create the queries
near the relevant code. For example, if you wanted to change the styles for buttons
on small devices, paste the mobile query code up in the buttons section and style it
there.
*/
/* Larger than mobile */
@media (min-width: 400px) {}
/* Larger than phablet (also point when grid becomes active) */
@media (min-width: 550px) {}
/* Larger than tablet */
@media (min-width: 750px) {}
/* Larger than desktop */
@media (min-width: 1000px) {}
/* Larger than Desktop HD */
@media (min-width: 1200px) {}

View File

@@ -0,0 +1,187 @@
@import url('https://fonts.googleapis.com/css?family=Roboto&display=swap');
@import url('https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css');
*{ margin: 0; padding: 0;}
body{
font-family: 'Roboto', sans-serif;
font-style: normal;
font-weight: 300;
font-smoothing: antialiased;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 15px;
background: #eee;
}
.intro{
background: #fff;
padding: 60px 30px;
color: #333;
margin-bottom: 15px;
line-height: 1.5;
text-align: center;
}
.intro h1 {
font-size: 18pt;
padding-bottom: 15px;
}
.intro p{
font-size: 14px;
}
.action{
text-align: center;
display: block;
margin-top: 20px;
}
a.btn {
text-decoration: none;
color: #666;
border: 2px solid #666;
padding: 10px 15px;
display: inline-block;
margin-left: 5px;
}
a.btn:hover{
background: #666;
color: #fff;
transition: .3s;
-webkit-transition: .3s;
}
.btn:before{
font-family: FontAwesome;
font-weight: normal;
margin-right: 10px;
}
.github:before{content: "\f09b"}
.down:before{content: "\f019"}
.back:before{content:"\f112"}
.credit{
background: #fff;
padding: 12px;
font-size: 9pt;
text-align: center;
color: #333;
margin-top: 40px;
}
.credit span:before{
font-family: FontAwesome;
color: #e41b17;
content: "\f004";
}
.credit a{
color: #333;
text-decoration: none;
}
.credit a:hover{
color: #1DBF73;
}
.credit a:hover:after{
font-family: FontAwesome;
content: "\f08e";
font-size: 9pt;
position: absolute;
margin: 3px;
}
main{
background: #fff;
padding:: 20px;
}
article li{
color: #444;
font-size: 15px;
margin-left: 33px;
line-height: 1.5;
padding: 5px;
}
article h1,
article h2,
article h3,
article h4,
article p{
padding: 14px;
color: #333;
}
article p{
font-size: 15px;
line-height: 1.5;
}
@media only screen and (min-width: 720px){
main{
max-width: 720px;
margin-left: auto;
margin-right: auto;
padding: 24px;
}
}
.set-overlayer,
.set-glass,
.set-sticky {
cursor: pointer;
height: 45px;
line-height: 45px;
padding: 0 15px;
color: #333;
font-size: 16px;
}
.set-overlayer:after,
.set-glass:after,
.to-active:after,
.set-sticky:after {
font-family: FontAwesome;
font-size: 18pt;
position: relative;
float: right;
}
.set-overlayer:after,
.set-glass:after,
.set-sticky:after {
content: "\f204";
transition: .6s;
}
.to-active:after {
content: "\f205";
color: #008080;
transition: .6s;
}
.set-overlayer,
.set-glass,
.set-sticky,
.source,
.theme-tray {
margin: 10px;
background: #f2f2f2;
border-radius: 5px;
border: 2px solid #f1f1f1;
box-sizing: border-box;
}
/* Syntax Highlighter*/
pre.prettyprint {
padding: 15px !important;
margin: 10px;
border: 0 !important;
background: #f2f2f2;
overflow: auto;
}
.source {
white-space: pre;
overflow: auto;
max-height: 400px;
}
code{
border:1px solid #ddd;
padding: 2px;
border-radius: 2px;
}

View File

@@ -0,0 +1,11 @@
<?php
require_once dirname(__DIR__) . '/bootstrap.php';
$con = legacy_new_mysqli();
if ($con->connect_errno) {
echo 'Failed to connect to MySQL.';
}
?>

View File

@@ -0,0 +1,269 @@
<?php include ('./inc/header.php'); ?>
<?php
ini_set('max_execution_time', 120);
set_time_limit(120);
$producto = "";
$ean = "";
$autoGenerar = false;
$formato = isset($_POST['formato']) ? $_POST['formato'] : 'plano';
// Si viene por URL
if (isset($_GET['name']) && !empty($_GET['name'])) {
$producto = urldecode($_GET['name']);
$autoGenerar = true;
}
// Si viene por POST
if (isset($_POST['prompt'])) {
$producto = trim($_POST['prompt']);
$autoGenerar = true;
}
// Detectar EAN (8 a 13 dígitos seguidos)
if (preg_match('/\b\d{8,13}\b/', $producto, $matches)) {
$ean = $matches[0];
}
if ($autoGenerar && !empty($producto)) {
// Prompt según formato seleccionado
if ($formato === 'plano') {
$prompt = "
Eres un redactor SEO experto en productos naturales, ecológicos y saludables.
Genera una descripción en texto plano sin formato ni negritas ni guiones, lista para pegar en CKEditor.
Sobre el producto: \"$producto\"
Instrucciones:
1. Prioriza la información del fabricante o distribuidor oficial.
2. Si el producto tiene un código EAN, utilízalo para obtener información nutricional en OpenFoodFacts.
3. No menciones ni enlaces fuentes externas.
4. No inventes datos no verificables.
Estructura del texto:
Descripción:
Ingredientes:
Información nutricional (por 100 g):
Beneficios para la salud:
Por qué deberías probarlo:
Keywords:
Reglas:
- No uses HTML, JSON ni emojis.
- No repitas el nombre del producto en exceso.
";
} else {
$prompt = "
Eres un redactor SEO especializado en productos naturales y saludables.
Genera una descripción optimizada, estructurada y lista para publicación web.
Producto: \"$producto\"
Prioriza datos del fabricante o distribuidor oficial, y si el producto tiene un código EAN, usa OpenFoodFacts para complementar la información nutricional.
No incluyas ni menciones enlaces externos, ni nombres de tiendas online o marketplaces.
Estructura del texto:
### Descripción
Breve texto atractivo sobre origen, uso y beneficios.
### Ingredientes
Lista completa y verificada.
### Información nutricional (por 100 g)
Calorías, grasas, hidratos, azúcares, proteínas, fibra y sal. Indica si provienen del fabricante o fuente pública.
### Beneficios para la salud
Texto breve explicando las propiedades y usos.
### Por qué deberías probarlo
Cierre aspiracional, natural y coherente.
### Keywords
Lista de términos relevantes separados por comas.
Reglas:
- No uses HTML ni JSON.
- No uses emojis.
- No menciones fuentes ni enlaces externos.
";
}
$respuesta = obtener_respuesta($prompt);
// Detectar enlaces válidos (solo OpenFoodFacts o fabricante)
$fuentes = [];
// --- Verificar si el EAN existe realmente en OpenFoodFacts ---
if (!empty($ean)) {
$url_off = "https://world.openfoodfacts.org/product/$ean";
$headers = @get_headers($url_off);
if ($headers && strpos($headers[0], '200') !== false) {
$fuentes[] = "Información verificada en OpenFoodFacts: $url_off";
}
}
// --- Verificar si existe ficha oficial del fabricante (ejemplo: Terpenic Labs) ---
$producto_slug = strtolower(str_replace(' ', '-', $producto));
$url_fabricante = "https://www.terpenic.com/product-page/" . urlencode($producto_slug);
$headers = @get_headers($url_fabricante);
if ($headers && strpos($headers[0], '200') !== false) {
$fuentes[] = "Ficha oficial del fabricante: $url_fabricante";
}
// --- Agregar los enlaces válidos al final del texto (copiable) ---
if (!empty($fuentes)) {
$respuesta .= "\n\n" . implode("\n", $fuentes);
}
}
function obtener_respuesta($prompt) {
$apiKey = trim((string) legacy_config('openai.api_key', ''));
$model = legacy_config('openai.model', 'gpt-4o-mini');
$endpoint = legacy_config('openai.endpoint', 'https://api.openai.com/v1/chat/completions');
if ($apiKey === '' || strpos($apiKey, 'CHANGE_ME_') === 0) {
return "⚠️ Configura openai.api_key en config/local.php.";
}
$ch = curl_init($endpoint);
$data = array(
'model' => $model,
'messages' => array(
array('role' => 'system', 'content' => 'Eres un redactor SEO experto en e-commerce.'),
array('role' => 'user', 'content' => $prompt)
),
'temperature' => 0.6,
'max_tokens' => 1200
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
'Content-Type: application/json',
'Authorization: Bearer ' . $apiKey
));
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_TIMEOUT, 120);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 30);
$result = curl_exec($ch);
if (curl_errno($ch)) {
return "⚠️ Error de conexión: " . curl_error($ch);
}
$http_status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_status !== 200) {
return "⚠️ Error HTTP $http_status — el servidor no respondió correctamente.";
}
$response = json_decode($result, true);
if (isset($response['choices'][0]['message']['content'])) {
return $response['choices'][0]['message']['content'];
} else {
return "⚠️ No se pudo generar respuesta. Detalle:\n" . json_encode($response, JSON_PRETTY_PRINT);
}
}
?>
<style>
.loader-container {
display: none;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 30px;
}
.loader {
border: 5px solid #f3f3f3;
border-top: 5px solid #0074D9;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 1s linear infinite;
}
@keyframes spin { 0% { transform: rotate(0deg);} 100% { transform: rotate(360deg);} }
.loading-text {
margin-top: 10px;
font-weight: bold;
font-size: 1.1rem;
color: #333;
animation: fadeText 1.5s infinite;
}
@keyframes fadeText {
0%,100% {opacity: 0.2;} 50% {opacity: 1;}
}
</style>
<div class="container">
<form method="POST" onsubmit="showLoader()">
<div class="row" style="margin-top: 20px">
<div class="ten columns">
<input class="u-full-width" type="text" name="prompt" id="prompt"
placeholder="Ingresa el nombre del producto"
value="<?php echo htmlspecialchars($producto); ?>">
</div>
<div class="two columns">
<button class="button button-primary u-full-width" type="submit">Generar</button>
</div>
</div>
<div class="row" style="margin-top:10px;">
<label>
<input type="checkbox" name="formato" value="formato" <?php echo ($formato==='formato')?'checked':''; ?>>
<span>Con formato para SEO</span>
</label>
</div>
</form>
<div id="loader" class="loader-container">
<div class="loader"></div>
<div class="loading-text">Generando contenido...</div>
</div>
<?php if ($autoGenerar && !isset($respuesta)) { ?>
<script>document.addEventListener("DOMContentLoaded",()=>{document.getElementById("loader").style.display="flex";});</script>
<?php } ?>
<?php if (isset($respuesta)) { ?>
<script>document.addEventListener("DOMContentLoaded",()=>{document.getElementById("loader").style.display="none";});</script>
<div class="row" style="margin-top:20px;">
<div class="twelve columns">
<pre id="texto-copiar" style="background:#f9f9f9;padding:15px;border-radius:6px;white-space:pre-wrap;font-size:1rem;"><?php echo htmlspecialchars($respuesta); ?></pre>
</div>
</div>
<div class="row" style="margin-top:10px;">
<div class="two columns">
<button class="button button-primary u-full-width" onclick="refreshPage()">
<i class="fa fa-refresh fa-lg"></i>
</button>
</div>
<div class="ten columns">
<button class="u-full-width" onclick="copiarTexto()">Copiar texto</button>
</div>
</div>
<?php } ?>
</div>
<script>
function showLoader(){document.getElementById("loader").style.display="flex";}
function copiarTexto(){
var e=document.getElementById("texto-copiar");
var t=e.innerText;
navigator.clipboard.writeText(t);
e.style.backgroundColor="#ffffa0";
setTimeout(()=>{e.style.backgroundColor="";alert("Texto copiado al portapapeles");},500);
}
function refreshPage(){location.reload();}
</script>
<?php include ('./inc/footer.php'); ?>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -0,0 +1,27 @@
<fieldset>
<?php
$atributos = mysqli_query($con," SELECT `oc_attribute_description`.`attribute_id` , `oc_attribute_description`.`language_id` , `oc_attribute_description`.`name` FROM `oc_attribute_description` WHERE `oc_attribute_description`.`language_id` = '4' ORDER BY `oc_attribute_description`.`name`");
$grupo = 0;
$row_count = mysqli_num_rows($atributos);
//echo $row_count;
while ($row = mysqli_fetch_assoc($atributos))
{
if ($grupo == 0) {
echo '<div class="three columns">';
}
echo '<input type="checkbox" name="atributos[]" value="'. $row['attribute_id'] .'" > &nbsp;' . ucwords(str_replace("-"," ", $row['name'])) . ' &nbsp; <br>';
$grupo++;
if ($grupo == 4) {
echo '</div>';
$grupo = 0;
}
}
?>
</fieldset>

View File

@@ -0,0 +1,184 @@
<!-- SCRIPTS JS
-->
<!-- EDITOR CKEDITOR
-->
<script src="https://code.jquery.com/jquery.min.js"></script>
<script src="https://cdn.ckeditor.com/ckeditor5/22.0.0/classic/ckeditor.js"></script>
<script>
ClassicEditor
.create( document.querySelector( '#editor' ) )
.then( editor => {
console.log( editor );
} )
.catch( error => {
console.error( error );
} );
</script>
<!-- SEO URL
-->
<script src="./js/slugify.js"></script>
<script>
$(document).ready(function(){
$('#slug,#slug-span').slugify('#slug-source');
});
</script>
<!-- CONVIERTE TEXTO A MAYÚSCULAS
-->
<script>
$(".text-uppercase").keyup(function () {
this.value = this.value.toLocaleUpperCase();
this.value = this.value.replace(/['"]+/g, '');
});
</script>
<!-- CONVIERTE TEXTO A MINÚSCULAS
-->
<script>
$(".text-lowercase").keyup(function () {
this.value = this.value.toLocaleLowerCase();
this.value = this.value.replace(/['"]+/g, '');
});
</script>
<!-- AJAX - REFRESCA LISTA DE MARCAS
-->
<script>
function refreshBrand()
{
$.ajax({
url: './inc/marcas.php',
type: 'post',
success: function(data) {
$('.newbrand').html(data);
}
});
}
</script>
<!-- VENTANA MODAL CREAR NUEVA MARCA
-->
<script>
// GET THE MODAL
var modal = document.getElementById("marcanueva");
// GET THE BUTTON THAT OPENS THE MODAL
var btn = document.getElementById("newbrand");
// GET THE <SPAN> ELEMENT THAT CLOSES THE MODAL
var span = document.getElementsByClassName("close")[0];
// WHEN THE USER CLICKS THE BUTTON, OPEN THE MODAL
btn.onclick = function() {
modal.style.display = "block";
}
// WHEN THE USER CLICKS ON <SPAN> (X), CLOSE THE MODAL
span.onclick = function() {
modal.style.display = "none";
}
// WHEN THE USER CLICKS ANYWHERE OUTSIDE OF THE MODAL, CLOSE IT
window.onclick = function(event) {
if (event.target == modal) {
modal.style.display = "none";
}
}
// WHEN THE USER CLICKS THE BUTTON <GUARDAR>, CLOSE THE MODAL
function cerrarModal(idModal) {
var idModal = idModal;
var modala = document.getElementById(idModal);
modala.style.display = "none";
return false;
}
</script>
<!-- AJAX - CREAR NUEVA MARCA
-->
<script>
function createBrand()
{
var nombreMarca= $("#nombreMarca").val();
$.ajax({
url: './inc/newmarca.php',
type: 'post',
data: "nombreMarca=" + nombreMarca,
beforeSend: function(){
$('#guardarMarca').hide();
$('#loading').show();
},
complete: function(){
$('#loading').hide();
$('#guardarMarca').show();
cerrarModal('marcanueva');
},
success: function(data) {
refreshBrand();
}
});
}
</script>
<!-- ENTER - STOP SUBMITTING FORM
-->
<script>
document.getElementById("newproduct").onkeypress = function(e) {
var key = e.charCode || e.keyCode || 0;
if (key == 13) {
e.preventDefault();
}
}
</script>
<!-- INPUT 'DATE' PARA SAFARI
-->
<script src="https://cdn.jsdelivr.net/webshim/1.12.4/extras/modernizr-custom.js"></script>
<script src="https://cdn.jsdelivr.net/webshim/1.12.4/polyfiller.js"></script>
<script>
webshims.setOptions('waitReady', false);
webshims.setOptions('forms-ext', {type: 'date'});
webshims.setOptions('forms-ext', {type: 'time'});
webshims.polyfill('forms forms-ext');
</script>
<script>
function setLink() {
var nameValue = document.getElementById("slug-source").value;
var brandSelect = document.getElementById("marca");
var brandValue = brandSelect.options[brandSelect.selectedIndex].text;
var url = "./describe.php?name=" + encodeURIComponent(nameValue) + " " + encodeURIComponent(brandValue);
document.getElementById("ia_link").href = url;
}
function validateSelect() {
var brandSelect = document.getElementById("marca");
var ia_link = document.getElementById("ia_link");
if (brandSelect.value) {
ia_link.style.display = "block";
var element = document.getElementById("url_div");
element.className = "ten columns";
} else {
ia_link.style.display = "none";
}
}
</script>
<!-- FIN SCRIPTS JS
-->
</body>
</html>

View File

@@ -0,0 +1,49 @@
<?php
require_once dirname(__DIR__) . '/bootstrap.php';
session_start();
$_SESSION['after_login'] = $_SERVER['REQUEST_URI'];
$_SESSION['acceso'] = TRUE;
if (empty($_SESSION['logged'])) {
header('Location: ' . legacy_config('routes.login_url', '../login.php'));
exit;
}
require_once dirname(__DIR__) . '/db/conn.php';
?>
<!DOCTYPE html>
<html lang="es">
<head>
<!-- Basic Page Needs
-->
<meta charset="utf-8">
<title>Nuevo Producto</title>
<meta name="description" content="Carga rápida de productos - Natural - Mercado de Vida">
<meta name="author" content="rikrdo.es">
<!-- Mobile Specific Metas
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- FONT
-->
<link href="https://fonts.googleapis.com/css?family=Raleway:400,300,600" rel="stylesheet" type="text/css">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css" rel="stylesheet" integrity="sha384-T8Gy5hrqNKT+hzMclPo118YTQO6cYprQmhrYwIiQ/3axmI1hQomh7Ud2hPOy8SP1" crossorigin="anonymous">
<!-- CSS
-->
<link rel="stylesheet" href="./css/normalize.css?<?php echo filemtime('./css/normalize.css') ?>" type="text/css">
<link rel="stylesheet" href="./css/skeleton.css?<?php echo filemtime('./css/skeleton.css') ?>" type="text/css">
<link rel="stylesheet" href="./css/modal.css?<?php echo filemtime('./css/modal.css') ?>" type="text/css">
<link rel="stylesheet" href="./css/custom.css?<?php echo filemtime('./css/custom.css') ?>" type="text/css">
<!-- Favicon
-->
<link rel="icon" type="image/png" href="../images/favicon.png">
</head>
<body>

View File

@@ -0,0 +1,12 @@
<?php require ('../db/conn.php');?>
<select name="marca" class="u-full-width" id="marca" required>
<option value="" selected disabled>MARCA</option>
<?php
$marcas = mysqli_query($con," SELECT `oc_manufacturer`.`manufacturer_id` , `oc_manufacturer`.`name` FROM `oc_manufacturer` ORDER BY `oc_manufacturer`.`name`");
while ($row = mysqli_fetch_assoc($marcas))
{
echo '<option value="'. $row['manufacturer_id'] .'" >' . $row['name'] . ' </option>';
}
?>
</select>

View File

@@ -0,0 +1,31 @@
<?php
require ('../db/conn.php');
$nombreMarca=trim($_POST['nombreMarca']);
setlocale(LC_ALL, 'en_US.UTF8'); // NECESARIO PARA CONVERTIR CARACTERES ESPECIALES AL ALFABETO INGLÉS
$urlMarca = htmlspecialchars($nombreMarca);
$urlMarca = html_entity_decode($urlMarca);
$urlMarca = strip_tags($urlMarca);
$urlMarca = preg_replace("/[^a-z0-9 ]/i", " ", iconv('UTF-8', 'ASCII//TRANSLIT', $urlMarca)); // ELIMINA CARACTERES ESPECIALES
$urlMarca = preg_replace("/\s+/", " ", $urlMarca); // QUITA DOBLE ESPACIO
$urlMarca = trim($urlMarca); // QUITA ESPACIO AL INICIO Y AL FINAL
$urlMarca = strtolower(str_replace(" ", "-", $urlMarca)); // REEMPLAZA ESPACIO POR GUION
$insert_manufacturer = "INSERT INTO `oc_manufacturer` (`manufacturer_id`,`name`, `image`, `sort_order`) VALUES (NULL , '". $nombreMarca ."', '', 0)";
if (mysqli_query($con, $insert_manufacturer))
{
$last_id = mysqli_insert_id($con);
mysqli_query($con, "INSERT INTO `oc_manufacturer_to_store` (`manufacturer_id`, `store_id`) VALUES (". $last_id .", 0)");
mysqli_query($con, "INSERT INTO `oc_url_alias` (`url_alias_id`, `query`, `keyword`)
VALUES (NULL , 'manufacturer_id=" . $last_id . "' , '" . $urlMarca . "')");
}
else
{
echo "<code>Error: " . $insert_manufacturer . "</code><br>" . mysqli_error($con);
}
mysqli_close($con);
?>

View File

@@ -0,0 +1,50 @@
You are an expert SEO copywriter specialized in natural, organic, and healthy products.
Produce an HTML-formatted description that feels authoritative and practical.
Use **bold** text and lists when helpful; keep emojis limited to the section labels provided below.
Product name: "$producto"
EAN (if any): "$ean"
General rules:
- Prioritize information from the official manufacturer or distributor.
You may consult these sites only as silent references (never mention or link them): nutritienda.com, veritas.es, naturitas.es, iherb.com, dietisur.es, openfoodfacts.org.
- Do not invent unverifiable facts. When data is missing, state that it is pending confirmation from the manufacturer.
- Only reference external sources if they are the official manufacturer or (when appropriate) OpenFoodFacts.
- Avoid `<h1>`, `<h2>`, `<h3>` tags. Stick to `<p>`, `<b>`, `<ul>`, `<li>`, `<h4>`, `<i>`, `<br>`.
- Never wrap the answer in Markdown code fences (` ``` `) or any kind of code block; return plain HTML only.
- Keep the tone informative, natural, and aligned with *Natural Mercado de Vida*.
- When an EAN is provided, query the OpenFoodFacts API `https://world.openfoodfacts.net/api/v2/product/{EAN}`.
* If the response indicates the product is missing or lacks meaningful fields (status ≠ 1 or no name/ingredients/nutrition), do **not** include the OpenFoodFacts link and note that information is pending verification.
* Only add the public UI link (`https://world.openfoodfacts.org/product/{EAN}`) when the API returns substantial data for that product.
Determine the product category before writing:
1. **Edible products / beverages / dietary supplements:** include ingredients and nutritional information if available.
2. **Topical cosmetics or hygiene items (creams, soaps, etc.):** mention key ingredients or active compounds only when they are typically disclosed; otherwise explain that the composition is pending verification. No nutritional data.
3. **Household products (cleaners, detergents, etc.):** focus on functional ingredients or key features; no nutritional information.
4. **Accessories, utensils, equipment, grooming tools (e.g., combs, bottles, blenders):** skip ingredients and nutritional information entirely. Highlight materials, design details, usage tips, and care instructions.
5. If the product type does not fit any of the above, use judgment and omit sections that are clearly irrelevant.
Section guidelines (adapt as needed based on the category analysis):
🪴 **Description:**
Concise overview of purpose, origin, and main benefits. Always include this section.
🌿 **Ingredients / Key components:**
- Include only when the product category reasonably has a composition list (foods, supplements, cosmetics, household consumables).
- If data is missing, note that it is pending verification.
- For accessories or tools, replace this section with **Key Features** describing materials or build qualities.
🍎 **Nutritional information (per 100 g / per serving):**
- Provide full table (calories, fats, carbohydrates, sugars, proteins, fiber, salt) only for edible items or supplements when data exists.
- If unavailable, state it is pending verification.
- Omit this section entirely for non-ingestible products.
💚 **Health benefits / Functional benefits:**
Explain how the product supports wellbeing, personal care, or practical use. Adjust wording to fit the category.
**Why you should try it / Usage tips / Care instructions:**
Close with an aspirational or practical paragraph encouraging its use, tailored to the product type.
OpenFoodFacts link:
If the item is edible or a supplement **and** the EAN is available, add a final sentence linking to the corresponding OpenFoodFacts page. Do not add this link for non-food items.

View File

@@ -0,0 +1,49 @@
Eres un redactor SEO experto en productos naturales, ecológicos y saludables.
Genera una descripción en HTML que resulte rigurosa y útil para la tienda.
Nombre del producto: "$producto"
EAN (si existe): "$ean"
Reglas generales:
- Prioriza la información del fabricante o distribuidor oficial.
Puedes consultar estas webs solo como referencia interna (no las cites ni enlaces): nutritienda.com, veritas.es, naturitas.es, iherb.com, dietisur.es, openfoodfacts.org.
- No inventes datos que no puedas respaldar. Si la información falta, indícalo como pendiente de verificación en la web del fabricante.
- Solo menciona fuentes externas si son el fabricante u OpenFoodFacts cuando corresponda.
- Emplea únicamente etiquetas HTML sencillas: `<p>`, `<b>`, `<ul>`, `<li>`, `<h4>`, `<i>`, `<br>`. Evita `<h1>`, `<h2>`, `<h3>`.
- No devuelvas el contenido dentro de bloques de código ni fences Markdown (` ``` `); responde únicamente con HTML plano.
- Estilo profesional, cercano y acorde a *Natural Mercado de Vida*.
- Cuando exista EAN, consulta la API `https://world.openfoodfacts.net/api/v2/product/{EAN}`.
* Si la respuesta indica que el producto no existe o no aporta datos relevantes (status ≠ 1 o sin nombre/ingredientes/nutrición), no añadas el enlace a OpenFoodFacts e indica que la información está pendiente.
* Solo agrega el enlace público (`https://world.openfoodfacts.org/product/{EAN}`) cuando la API devuelva información sustancial del producto.
Antes de redactar, identifica el tipo de producto:
1. **Alimentos, bebidas o complementos alimenticios:** incluye ingredientes y tabla nutricional si están disponibles.
2. **Cosmética o higiene (cremas, champús, jabones, etc.):** menciona ingredientes o activos principales solo si suelen publicarse; de lo contrario, explica que la composición está pendiente de verificación. No aportes información nutricional.
3. **Limpieza del hogar y detergentes:** describe ingredientes funcionales o características clave; no incluyas datos nutricionales.
4. **Accesorios, utensilios, equipamiento, herramientas de cuidado personal (ej. peines, botellas, batidoras):** omite ingredientes e información nutricional. En su lugar, destaca materiales, diseño, uso y mantenimiento.
5. Si el producto no encaja en las categorías anteriores, aplica criterio y elimina las secciones que no sean pertinentes.
Guía de secciones (adáptalas según lo anterior):
🪴 **Descripción:**
Resumen breve sobre finalidad, origen y beneficios principales. Siempre debe aparecer.
🌿 **Ingredientes / Componentes clave / Características:**
- Usa “Ingredientes” solo cuando el producto realmente tenga una lista de composición.
- Para accesorios o herramientas, cambia el título por “Características principales” y describe materiales, tecnología o acabados.
- Si no hay datos confirmados, indica que están pendientes de verificación.
🍎 **Información nutricional (por 100 g o por dosis):**
- Solo para alimentos o complementos cuando haya datos.
- Si la información no está disponible, menciona que falta confirmación.
- Elimina por completo esta sección cuando el producto no sea ingerible.
💚 **Beneficios para la salud / Beneficios funcionales:**
Explica cómo contribuye al bienestar, cuidado personal o utilidad práctica según el caso.
**Por qué deberías probarlo / Consejos de uso / Mantenimiento:**
Cierre inspirador o práctico que anime a utilizarlo, adaptado al tipo de producto.
Enlace a OpenFoodFacts:
Si es un producto comestible o suplemento y el EAN está disponible, añade al final una frase con enlace a su ficha en OpenFoodFacts. No incluyas este enlace para artículos no alimentarios.

View File

@@ -0,0 +1,190 @@
<?php include ('./inc/header.php'); ?>
<div class="container">
<!-- FORMULARIO
-->
<form enctype="multipart/form-data" action="<?php echo htmlspecialchars(legacy_config('routes.success_url', '')); ?>" name = "newproduct" id = "newproduct" method = "POST">
<input name="pwd" type="hidden" value="<?php echo htmlspecialchars(legacy_config('security.form_password_hash', '')); ?>">
<!-- CODIGO, SEO URL
-->
<div class="row" style = "margin-top: 20px">
<div class="ten columns" id = "url_div">
<span class="slug-ouput"> <b><input type ="text" name="url" value="" placeholder="SEO URL" class="u-full-width" id="slug" tabindex="-1" /></b></span>
</div>
<div class="two columns">
<a id="ia_link" href="" class="button button-primary u-full-width" target="_blank"><i class="fa fa-lightbulb-o fa-lg" aria-hidden="true"></i></a>
</div>
</div>
<!-- NOMBRE DEL PRODUCTO
-->
<div class="row" >
<div class="nine columns">
<label for="slug-source">Nombre del Artículo: </label>
<input type ="text" name="nombre" value="" class="u-full-width text-uppercase" id="slug-source" oninput="setLink()" required />
</div>
<div class="three columns">
<label for="slug-source">EAN: </label>
<input type ="text" name="ean" value="" class="u-full-width" id="ean" />
</div>
</div>
<!-- CATEGORIAS, MARCA
-->
<div class="row">
<div class="six columns">
<select name="categoria" class="u-full-width" id="categoria" required >
<option value="" selected disabled>CATEGORÍA</option>
<?php
$categorias = mysqli_query($con," SELECT * FROM `oc_category`, `oc_category_description`, `oc_category_to_store` WHERE `oc_category`.`category_id` = `oc_category_description`.`category_id` AND `oc_category_description`.`category_id`= `oc_category_to_store`.`category_id` AND `oc_category_description`.`language_id` = 4 ORDER BY `oc_category_description`.`name` ASC");
while ($row = mysqli_fetch_assoc($categorias))
{
echo '<option value="'. $row['category_id'] .'" >' . $row['name'] . ' </option>';
}
?>
</select>
</div>
<div class="four columns newbrand">
<select name="marca" class="u-full-width" id="marca" onchange="validateSelect(),setLink()" required>
<option value="" selected disabled>MARCA</option>
<?php
$marcas = mysqli_query($con," SELECT `oc_manufacturer`.`manufacturer_id` , `oc_manufacturer`.`name` FROM `oc_manufacturer` ORDER BY `oc_manufacturer`.`name`");
while ($row = mysqli_fetch_assoc($marcas))
{
echo '<option value="'. $row['manufacturer_id'] .'" >' . $row['name'] . ' </option>';
}
?>
</select>
</div>
<div class="two columns">
<!-- Abre Ventana Modal -->
<span class="button button-primary u-full-width" id="newbrand"><i class="fa fa-pencil fa-lg" aria-hidden="true"></i></span>
</div>
</div>
<!-- PVP, COSTE, IVA
-->
<div class="row">
<div class="four columns">
<label for="pvp">PVP: </label>
<input type ="number" name="pvp" value="" class="u-full-width" id="pvp" step="any" required />
</div>
<div class="four columns">
<label for="coste">Coste: </label>
<input type ="number" name="coste" value="" class="u-full-width" id="coste" step="any" required />
</div>
<div class="four columns">
<label for="iva">IVA: </label>
<select name="iva" class="u-full-width" id="iva" required >
<?php
$iva = mysqli_query($con," SELECT `tax_class_id` , `title` FROM `oc_tax_class` ORDER BY `title` ASC");
while ($row = mysqli_fetch_assoc($iva))
{
echo '<option value="'. $row['tax_class_id'] .'" >' . substr($row['title'], 4) . ' </option>';
}
?>
</select>
</div>
</div>
<!-- CANTIDAD, CADUCIDAD, PESO
-->
<div class="row">
<div class="four columns">
<label for="cantidad">Cantidad: </label>
<input type ="number" name="cantidad" value="" class="u-full-width" id="cantidad" required>
</div>
<div class="four columns">
<label for="caducidad">Caducidad: </label>
<input type ="date" name="caducidad" value="" min="<?php echo date('Y-m-d');?>" class="u-full-width" id="caducidad" required />
</div>
<div class="four columns">
<label for="peso">Peso: </label>
<select name="peso" class="u-full-width" id="peso" required>
<option value="0.100">100gr</option>
<option value="0.250" selected>250gr</option>
<option value="0.500">500gr</option>
<option value="0.750">750gr</option>
<option value="1.000">1kg</option>
<option value="1.500">1,5kg</option>
<option value="2.000">2kg</option>
</select>
</div>
</div>
<!-- FIELDSET ATRIBUTOS
-->
<div class="row" id="atrib_zone">
<div class="twelve columns">
<?php include ('inc/atributos.php'); ?>
</div>
</div>
<!-- EDITOR JS
-->
<div class="row" style = "margin-top: 20px">
<div class="twelve columns">
<textarea name="editor" id="editor" placeholder="Descripcion del producto..." class="u-full-width"></textarea>
</div>
</div>
<!-- CARGAR IMAGEN X URL
-->
<div class="row" style = "margin-top: 20px">
<div class="twelve columns">
<input type ="text" name="image_path" id="image_path" class="u-full-width" placeholder="Pegar URL de la Imagen" onchange="document.getElementById('imgElement').src = $('#image_path').val()" />
</div>
</div>
<!-- UPLOAD IMAGEN LOCAL
-->
<div class="row" style = "margin-top: 20px">
<div class="four columns" id="imgPreview">
<img id="imgElement" alt="Imagen de Producto" src="./images/nopic.png" />
</div>
<div class="four columns" >
<p class="infoText">* Formatos soportados JPG y PNG</p>
<label for="imagen" class="uploadImg">
<i class="fa fa-cloud-upload"> </i> Cargar Imágen
</label>
<input type="file" class="u-full-width" name="imagen" id="imagen" onchange="document.getElementById('imgElement').src = window.URL.createObjectURL(this.files[0])">
</div>
<div class="four columns" >
<button type="submit" class="button-primary u-full-width" name="submit" id="crearProducto" >Guardar Producto</button>
</div>
</div>
</form>
</div>
<!-- VENTANA MODAL 'NUEVA MARCA'
-->
<div id="marcanueva" class="modal">
<!-- CONTENIDO VENTANA MODAL -->
<div class="modal-content">
<span class="close">&times;</span>
<form enctype="multipart/form-data" action="inc/newmarca.php" method = "POST">
<div class="row" >
<div class="twelve columns">
<input type ="text" name="nombreMarca" value="" class="u-full-width text-uppercase" id="nombreMarca" onfocus="this.value=''" placeholder="Nombre de la Marca" required />
</div>
<div class="modalfoot" >
<button onclick="createBrand();return false" class="button button-primary u-full-width"id="guardarMarca" >Guardar</button>
<button class="button button-primary u-full-width" id="loading" disabled><i class="fa fa-spinner fa-spin"></i> Guardando...</button>
</div>
</div>
</form>
</div>
</div>
<?php
mysqli_close($con);
include ('./inc/footer.php');
?>

View File

@@ -0,0 +1,613 @@
/*! Slugify - v0.1.0 - 2013-05-22
* https://github.com/madflow/jquery-slugify
* Copyright (c) 2013 madflow; Licensed MIT */
;(function($) {
$.fn.slugify = function(source, options) {
return this.each(function() {
var $target = $(this),
$source = $(source);
$target.on('keyup change',function() {
if($target.val() !== '' && $target.val() !== undefined) {
$target.data('locked', true);
} else {
$target.data('locked', false);
}
});
$source.on('keyup change',function() {
if( true === $target.data('locked')) {return;}
if($target.is('input') || $target.is('textarea')) {
$target.val($.slugify($source.val(), options));
} else {
$target.text($.slugify($source.val(), options));
}
});
});
};
// Static method.
$.slugify = function(sourceString, options) {
// Override default options with passed-in options.
options = $.extend({}, $.slugify.options, options);
sourceString = $.trim(sourceString); // Trim
sourceString = sourceString.toLowerCase(); // Lower Case
$.each(options.replaceMap, function(key, value) { // Special char map
sourceString = sourceString.replace(new RegExp(key, 'g'), value || options.invalid);
});
return sourceString
.replace(/\s+/g, options.whitespace) // Replace whitespace characters
.replace(new RegExp('[^a-z0-9 '+ options.whitespace +']', 'g'), options.invalid); // Replace invalid characters
};
// Default options
$.slugify.options = {
whitespace: '-',
invalid: '',
replaceMap: {
'á': 'a',
'Ã ': 'a',
'â': 'a',
'ä': 'ae',
'ã': 'a',
'æ': 'ae',
'ç': 'c',
'é': 'e',
'è': 'e',
'ê': 'e',
'ë': 'e',
'ẽ': 'e',
'í': 'i',
'ì': 'i',
'î': 'i',
'ï': 'i',
'Ä©': 'i',
'ó': 'o',
'ò': 'o',
'ô': 'o',
'ö': 'oe',
'õ': 'o',
'Å“': 'oe',
'ß': 'ss',
'ú': 'u',
'ù': 'u',
'û': 'u',
'ü': 'ue',
'Å©': 'u',
'ă': 'a',
'ắ': 'a',
'ằ': 'a',
'ẵ': 'a',
'ẳ': 'a',
'ấ': 'a',
'ầ': 'a',
'ẫ': 'a',
'ẩ': 'a',
'ÇŽ': 'a',
'Ã¥': 'a',
'Ç»': 'a',
'ÇŸ': 'a',
'ȧ': 'a',
'Ç¡': 'a',
'Ä…': 'a',
'ā': 'a',
'ả': 'a',
'ȁ': 'a',
'ȃ': 'a',
'ạ': 'a',
'ặ': 'a',
'ậ': 'a',
'ḁ': 'a',
'â±¥': 'a',
'ᶏ': 'a',
'ɐ': 'a',
'É‘': 'a',
'ḃ': 'b',
'ḅ': 'b',
'ḇ': 'b',
'Æ€': 'b',
'É“': 'b',
'ƃ': 'b',
'ᵬ': 'b',
'á¶€': 'b',
'þ': 'b',
'ć': 'c',
'ĉ': 'c',
'č': 'c',
'Ä‹': 'c',
'ḉ': 'c',
'ȼ': 'c',
'ƈ': 'c',
'É•': 'c',
'ď': 'd',
'ḋ': 'd',
'ḑ': 'd',
'ḍ': 'd',
'ḓ': 'd',
'ḏ': 'd',
'Ä‘': 'd',
'É–': 'd',
'É—': 'd',
'ƌ': 'd',
'áµ­': 'd',
'ᶁ': 'd',
'á¶‘': 'd',
'È¡': 'd',
'∂': 'd',
'Ä•': 'e',
'ế': 'e',
'ề': 'e',
'á»…': 'e',
'ể': 'e',
'Ä›': 'e',
'Ä—': 'e',
'È©': 'e',
'ḝ': 'e',
'Ä™': 'e',
'Ä“': 'e',
'ḗ': 'e',
'ḕ': 'e',
'ẻ': 'e',
'È…': 'e',
'ȇ': 'e',
'ẹ': 'e',
'ệ': 'e',
'ḙ': 'e',
'ḛ': 'e',
'ɇ': 'e',
'á¶’': 'e',
'ḟ': 'f',
'Æ’': 'f',
'áµ®': 'f',
'á¶‚': 'f',
'ǵ': 'g',
'ÄŸ': 'g',
'ĝ': 'g',
'ǧ': 'g',
'Ä¡': 'g',
'Ä£': 'g',
'ḡ': 'g',
'Ç¥': 'g',
'É ': 'g',
'ᶃ': 'g',
'Ä¥': 'h',
'ÈŸ': 'h',
'ḧ': 'h',
'ḣ': 'h',
'ḩ': 'h',
'ḥ': 'h',
'ḫ': 'h',
'ẖ': 'h',
'ħ': 'h',
'ⱨ': 'h',
'Ä­': 'i',
'ǐ': 'i',
'ḯ': 'i',
'į': 'i',
'Ä«': 'i',
'ỉ': 'i',
'ȉ': 'i',
'È‹': 'i',
'ị': 'i',
'ḭ': 'i',
'ɨ': 'i',
'áµ»': 'i',
'á¶–': 'i',
'i': 'i',
'ı': 'i',
'ĵ': 'j',
'ɉ': 'j',
'ǰ': 'j',
'È·': 'j',
'ʝ': 'j',
'ÉŸ': 'j',
'Ê„': 'j',
'ḱ': 'k',
'Ç©': 'k',
'Ä·': 'k',
'ḳ': 'k',
'ḵ': 'k',
'Æ™': 'k',
'ⱪ': 'k',
'á¶„': 'k',
'ĺ': 'l',
'ľ': 'l',
'ļ': 'l',
'ḷ': 'l',
'ḹ': 'l',
'ḽ': 'l',
'ḻ': 'l',
'Å‚': 'l',
'Å€': 'l',
'Æš': 'l',
'ⱡ': 'l',
'É«': 'l',
'ɬ': 'l',
'á¶…': 'l',
'É­': 'l',
'È´': 'l',
'ḿ': 'm',
'ṁ': 'm',
'ṃ': 'm',
'ᵯ': 'm',
'ᶆ': 'm',
'ɱ': 'm',
'Å„': 'n',
'ǹ': 'n',
'ň': 'n',
'ñ': 'n',
'á¹…': 'n',
'ņ': 'n',
'ṇ': 'n',
'ṋ': 'n',
'ṉ': 'n',
'n̈': 'n',
'ɲ': 'n',
'Æž': 'n',
'Å‹': 'n',
'áµ°': 'n',
'ᶇ': 'n',
'ɳ': 'n',
'ȵ': 'n',
'ŏ': 'o',
'ố': 'o',
'ồ': 'o',
'á»—': 'o',
'ổ': 'o',
'Ç’': 'o',
'È«': 'o',
'Å‘': 'o',
'ṍ': 'o',
'ṏ': 'o',
'È­': 'o',
'ȯ': 'o',
˜˜': 'o',
'ȱ': 'o',
'ø': 'o',
'Ç¿': 'o',
'Ç«': 'o',
'Ç­': 'o',
'ō': 'o',
'ṓ': 'o',
'ṑ': 'o',
'ỏ': 'o',
'ȍ': 'o',
'ȏ': 'o',
'Æ¡': 'o',
'á»›': 'o',
'ờ': 'o',
'ỡ': 'o',
'ở': 'o',
'ợ': 'o',
'ọ': 'o',
'á»™': 'o',
'ɵ': 'o',
'É”': 'o',
'ṕ': 'p',
'á¹—': 'p',
'áµ½': 'p',
'Æ¥': 'p',
'p̃': 'p',
'áµ±': 'p',
'ᶈ': 'p',
'É‹': 'q',
'Æ£': 'q',
'Ê ': 'q',
'Å•': 'r',
'Å™': 'r',
'á¹™': 'r',
'Å—': 'r',
'È‘': 'r',
'È“': 'r',
'á¹›': 'r',
'ṝ': 'r',
'ṟ': 'r',
'ɍ': 'r',
'ɽ': 'r',
'áµ²': 'r',
'ᶉ': 'r',
'ɼ': 'r',
'ɾ': 'r',
'áµ³': 'r',
'Å›': 's',
'á¹¥': 's',
'ŝ': 's',
'Å¡': 's',
'á¹§': 's',
'ṡẛ': 's',
'ÅŸ': 's',
'á¹£': 's',
'ṩ': 's',
'È™': 's',
's̩': 's',
'áµ´': 's',
'á¶Š': 's',
'Ê‚': 's',
'È¿': 's',
'Å¥': 't',
'ṫ': 't',
'Å£': 't',
'á¹­': 't',
'È›': 't',
'á¹±': 't',
'ṯ': 't',
'ŧ': 't',
'ⱦ': 't',
'Æ­': 't',
'ʈ': 't',
'̈ẗ': 't',
'áµµ': 't',
'Æ«': 't',
'ȶ': 't',
'Å­': 'u',
'Ç”': 'u',
'ů': 'u',
'ǘ': 'u',
'ǜ': 'u',
'Çš': 'u',
'Ç–': 'u',
'ű': 'u',
'á¹¹': 'u',
'ų': 'u',
'Å«': 'u',
'á¹»': 'u',
'á»§': 'u',
'È•': 'u',
'È—': 'u',
'ư': 'u',
'ứ': 'u',
'ừ': 'u',
'ữ': 'u',
'á»­': 'u',
'á»±': 'u',
'ụ': 'u',
'á¹³': 'u',
'á¹·': 'u',
'á¹µ': 'u',
'ʉ': 'u',
'áµ¾': 'u',
'á¶™': 'u',
'á¹½': 'v',
'ṿ': 'v',
'Ê‹': 'v',
'ᶌ': 'v',
'â±´': 'v',
'ẃ': 'w',
'ẁ': 'w',
'ŵ': 'w',
'ẅ': 'w',
'ẇ': 'w',
'ẉ': 'w',
'ẘ': 'w',
'ẍ': 'x',
'ẋ': 'x',
'ᶍ': 'x',
'ý': 'y',
'ỳ': 'y',
'Å·': 'y',
'ẙ': 'y',
'ÿ': 'y',
'ỹ': 'y',
'ẏ': 'y',
'ȳ': 'y',
'á»·': 'y',
'ỵ': 'y',
'ɏ': 'y',
'Æ´': 'y',
'ʏ': 'y',
'ź': 'z',
'ẑ': 'z',
'ž': 'z',
'ż': 'z',
'ẓ': 'z',
'ẕ': 'z',
'ƶ': 'z',
'È¥': 'z',
'ⱬ': 'z',
'áµ¶': 'z',
'á¶Ž': 'z',
'ʐ': 'z',
'Ê‘': 'z',
'É€': 'z',
'α': 'a',
'β': 'b',
'γ': 'g',
'É£': 'g',
'δ': 'd',
'ð': 'd',
'ε': 'e',
'ζ': 'z',
'η': 'i',
'θ': 'th',
'ι': 'i',
'κ': 'k',
'λ': 'l',
'μ': 'm',
'µ': 'm',
'ν': 'n',
'ξ': 'x',
'ο': 'o',
'Ï€': 'p',
'ρ': 'r',
'σ': 's',
'Ï‚': 's',
'Ï„': 't',
'Ï…': 'u',
'φ': 'f',
'χ': 'ch',
'ψ': 'ps',
'ω': 'o',
'á¾³': 'a',
'ά': 'a',
'á½°': 'a',
'á¾´': 'a',
'á¾²': 'a',
'á¾¶': 'a',
'á¾·': 'a',
'á¼€': 'a',
'á¾€': 'a',
'ἄ': 'a',
'ᾄ': 'a',
'ἂ': 'a',
'ᾂ': 'a',
'ἆ': 'a',
'ᾆ': 'a',
'ἁ': 'a',
'ᾁ': 'a',
'á¼…': 'a',
'á¾…': 'a',
'ἃ': 'a',
'ᾃ': 'a',
'ἇ': 'a',
'ᾇ': 'a',
'á¾±': 'a',
'á¾°': 'a',
'έ': 'e',
'á½²': 'e',
'ἐ': 'e',
'á¼”': 'e',
'á¼’': 'e',
'ἑ': 'e',
'ἕ': 'e',
'ἓ': 'e',
'ῃ': 'i',
'ή': 'i',
'á½´': 'i',
'á¿„': 'i',
'á¿‚': 'i',
'ῆ': 'i',
'ῇ': 'i',
'á¼ ': 'i',
'ᾐ': 'i',
'ἤ': 'i',
'á¾”': 'i',
'á¼¢': 'i',
'á¾’': 'i',
'ἦ': 'i',
'á¾–': 'i',
'ἡ': 'i',
'ᾑ': 'i',
'á¼¥': 'i',
'ᾕ': 'i',
'á¼£': 'i',
'ᾓ': 'i',
'á¼§': 'i',
'á¾—': 'i',
'ί': 'i',
'á½¶': 'i',
'á¿–': 'i',
'á¼°': 'i',
'á¼´': 'i',
'á¼²': 'i',
'á¼¶': 'i',
'á¼±': 'i',
'á¼µ': 'i',
'á¼³': 'i',
'á¼·': 'i',
'ÏŠ': 'i',
'ΐ': 'i',
'á¿’': 'i',
'á¿—': 'i',
'á¿‘': 'i',
'ῐ': 'i',
'ό': 'o',
'ὸ': 'o',
'á½€': 'o',
'ὄ': 'o',
'ὂ': 'o',
'ὁ': 'o',
'á½…': 'o',
'ὃ': 'o',
'ύ': 'u',
'ὺ': 'u',
'ῦ': 'u',
'ὐ': 'u',
'á½”': 'u',
'á½’': 'u',
'á½–': 'u',
'ὑ': 'u',
'ὕ': 'u',
'ὓ': 'u',
'á½—': 'u',
'Ï‹': 'u',
'ΰ': 'u',
'á¿¢': 'u',
'á¿§': 'u',
'á¿¡': 'u',
'á¿ ': 'u',
'ῳ': 'o',
'ÏŽ': 'o',
'á¿´': 'o',
'á½¼': 'o',
'ῲ': 'o',
'á¿¶': 'o',
'á¿·': 'o',
'á½ ': 'o',
'á¾ ': 'o',
'ὤ': 'o',
'ᾤ': 'o',
'á½¢': 'o',
'á¾¢': 'o',
'ὦ': 'o',
'ᾦ': 'o',
'ὡ': 'o',
'ᾡ': 'o',
'á½¥': 'o',
'á¾¥': 'o',
'á½£': 'o',
'á¾£': 'o',
'á½§': 'o',
'á¾§': 'o',
'ῤ': 'r',
'á¿¥': 'r',
'а': 'a',
'б': 'b',
'в': 'v',
'г': 'g',
'д': 'd',
'е': 'e',
'Ñ‘': 'e',
'ж': 'zh',
'з': 'z',
'и': 'i',
'й': 'j',
'к': 'k',
'л': 'l',
'м': 'm',
'н': 'n',
'о': 'o',
'п': 'p',
'Ñ€': 'r',
'с': 'n',
'Ñ‚': 't',
'у': 'u',
'Ñ„': 'f',
'Ñ…': 'h',
'ц': 'ts',
'ч': 'ch',
'ш': 'sh',
'щ': 'sh',
'ÑŠ': '',
'Ñ‹': 'i',
'ь': '',
'э': 'n',
'ÑŽ': 'yu',
'я': 'ya',
'Ñ–': 'j',
'ѳ': 'f',
'Ñ£': 'e',
'ѵ': 'i',
'Ñ•': 'z',
'ѯ': 'ks',
'ѱ': 'ps',
'Ñ¡': 'o',
'Ñ«': 'yu',
'ѧ': 'ya',
'Ñ­': 'yu',
'Ñ©': 'ya'
}
};
}(jQuery));

View File

@@ -0,0 +1,269 @@
<?php
require_once __DIR__ . '/bootstrap.php';
ini_set('max_execution_time', 120);
set_time_limit(120);
$LANG_ES = (int) legacy_config('store.language_es', 4);
$IMG_BASE = legacy_config('store.image_base_url', 'https://example.local/image/');
$LOG_PATH = legacy_config('paths.worker_log', __DIR__ . '/logs/worker.log');
if (isset($_GET['action']) && $_GET['action'] === 'log_tail') {
header('Content-Type: application/json; charset=utf-8');
$limit = isset($_GET['limit']) ? max(10, min(200, (int)$_GET['limit'])) : 80;
if (!file_exists($LOG_PATH)) {
echo json_encode(['ok' => false, 'message' => 'Aún no hay registros.']);
} else {
$lines = file($LOG_PATH, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$tail = array_slice($lines, -$limit);
echo json_encode([
'ok' => true,
'updated' => date('H:i:s'),
'log' => implode("\n", $tail)
]);
}
exit;
}
if (isset($_GET['action']) && $_GET['action'] === 'show_log') {
include('./inc/header.php');
$logEndpoint = 'productos_bulk_update.php?action=log_tail&limit=120';
echo "<div class='container'>
<h3>📊 Progreso del worker</h3>
<p>Puedes dejar esta ventana abierta para monitorear la cola en tiempo real.</p>
<div class='row'>
<div class='twelve columns'>
<div class='log-panel'>
<div class='log-panel__header'>
<strong>Últimas entradas</strong>
<span id='log-status' class='log-panel__status'>Actualizando...</span>
</div>
<pre id='log-viewer' class='log-panel__body'>Cargando log...</pre>
</div>
</div>
</div>
<div class='row'>
<div class='twelve columns'>
<button onclick=\"window.location.href='productos_bulk_update.php'\" class='button last'>← Volver al listado</button>
</div>
</div>
</div>
<script>
(function(){
const viewer = document.getElementById('log-viewer');
const status = document.getElementById('log-status');
const endpoint = '$logEndpoint';
const refresh = () => {
fetch(endpoint + '&_=' + Date.now(), {cache:'no-store'})
.then(r => r.json())
.then(data => {
if (!data.ok) {
viewer.textContent = data.message || 'Sin datos disponibles.';
status.textContent = 'Sin registros';
return;
}
viewer.textContent = data.log || 'Sin registros recientes.';
status.textContent = 'Actualizado ' + (data.updated || '');
viewer.scrollTop = viewer.scrollHeight;
})
.catch(() => {
status.textContent = 'Error al actualizar';
});
};
refresh();
setInterval(refresh, 5000);
})();
</script>";
include('./inc/footer.php');
exit;
}
include('./inc/header.php');
$db = legacy_new_mysqli();
if ($db->connect_errno) die("❌ Error DB: " . $db->connect_error);
/* ============================================
🧾 Insertar productos seleccionados a la cola
============================================ */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['productos'])) {
$productos = $_POST['productos'];
$inserted = 0;
foreach ($productos as $pid) {
$pid = (int)$pid;
$db->query("INSERT IGNORE INTO oc_product_queue (product_id) VALUES ($pid)");
if ($db->affected_rows > 0) {
$inserted++;
}
}
$logEndpoint = 'productos_bulk_update.php?action=log_tail&limit=120';
echo "<div class='container'>
<h3>✅ $inserted productos añadidos a la cola.</h3>
<p>El worker los procesará automáticamente por orden de llegada. Puedes seguir el progreso en tiempo real debajo.</p>
<div class='row'>
<div class='twelve columns'>
<div class='log-panel'>
<div class='log-panel__header'>
<strong>📊 Log del worker (últimas entradas)</strong>
<span id='log-status' class='log-panel__status'>Actualizando...</span>
</div>
<pre id='log-viewer' class='log-panel__body'>Cargando log...</pre>
</div>
</div>
</div>
<div class='row'>
<div class='twelve columns'>
<button class='button button-primary' onclick=\"window.location.href='productos_bulk_update.php'\">← Volver</button>
</div>
</div>
</div>
<script>
(function(){
const viewer = document.getElementById('log-viewer');
const status = document.getElementById('log-status');
const endpoint = '$logEndpoint';
const refresh = () => {
fetch(endpoint + '&_=' + Date.now(), {cache: 'no-store'})
.then(r => r.json())
.then(data => {
if (!data.ok) {
viewer.textContent = data.message || 'Sin datos disponibles.';
status.textContent = 'Sin registros';
return;
}
viewer.textContent = data.log || 'Sin registros recientes.';
status.textContent = 'Actualizado ' + (data.updated || '');
viewer.scrollTop = viewer.scrollHeight;
})
.catch(() => {
status.textContent = 'Error al actualizar';
});
};
refresh();
setInterval(refresh, 5000);
})();
</script>";
include('./inc/footer.php');
exit;
}
/* ============================================
📋 Listado de productos activos no procesados
============================================ */
$per_page = isset($_GET['per_page']) ? max(10, min(200, (int)$_GET['per_page'])) : 50;
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$sql_total = "
SELECT COUNT(*) AS total
FROM oc_product p
WHERE p.status = 1
AND p.product_id NOT IN (SELECT product_id FROM oc_product_queue)";
$res_total = $db->query($sql_total);
$total_rows = $res_total ? (int)$res_total->fetch_assoc()['total'] : 0;
$total_pages = max(1, (int)ceil($total_rows / $per_page));
if ($page > $total_pages) {
$page = $total_pages;
}
$offset = ($page - 1) * $per_page;
$q = "
SELECT p.product_id, p.image, d1.name, LEFT(d1.description, 250) AS descripcion
FROM oc_product p
LEFT JOIN oc_product_description d1
ON p.product_id=d1.product_id AND d1.language_id=$LANG_ES
WHERE p.status = 1
AND p.product_id NOT IN (SELECT product_id FROM oc_product_queue)
ORDER BY p.product_id ASC
LIMIT $per_page OFFSET $offset";
$res = $db->query($q);
?>
<div class="container">
<h3>🧾 Productos pendientes de optimización SEO</h3>
<div class="row">
<div class="eight columns">
<p>Total pendientes: <strong><?php echo $total_rows; ?></strong><br>
Página <strong><?php echo $page; ?></strong> de <strong><?php echo $total_pages; ?></strong></p>
</div>
<div class="four columns">
<form method="GET" class="row u-cf">
<div class="six columns">
<label for="per_page">products</label>
</div>
<div class="six columns">
<select class="u-full-width" name="per_page" id="per_page" onchange="this.form.submit()">
<?php foreach ([25, 50, 100, 150, 200] as $opt): ?>
<option value="<?php echo $opt; ?>" <?php echo $opt==$per_page?'selected':''; ?>><?php echo $opt; ?></option>
<?php endforeach; ?>
</select>
</div>
<input type="hidden" name="page" value="1">
<noscript><div class="four columns"><button type="submit" class="button">Actualizar</button></div></noscript>
</form>
</div>
</div>
<?php if ($total_rows === 0): ?>
<div class="row">
<div class="twelve columns alert-box">
<p>No hay productos pendientes de encolar.</p>
<button type="button" class="button" onclick="window.location.href='productos_bulk_update.php?action=show_log'">
Ver progreso del worker
</button>
</div>
</div>
<?php endif; ?>
<form method="POST">
<table class="u-full-width">
<thead>
<tr><th></th><th>ID</th><th>Imagen</th><th>Nombre</th><th>Descripción</th></tr>
</thead>
<tbody>
<?php while($r=$res->fetch_assoc()){
$img=!empty($r['image'])?$IMG_BASE.htmlspecialchars($r['image']):"https://via.placeholder.com/80x80?text=No+Image"; ?>
<tr>
<td><input type="checkbox" name="productos[]" value="<?php echo $r['product_id']; ?>" checked></td>
<td><?php echo $r['product_id']; ?></td>
<td><img src="<?php echo $img; ?>" alt=""></td>
<td><?php echo htmlspecialchars($r['name']); ?></td>
<td><div class="table-description"><?php echo nl2br(strip_tags($r['descripcion'])); ?></div></td>
</tr>
<?php } ?>
</tbody>
</table>
<div class="row">
<div class="twelve columns">
<button type="submit" class="button button-primary"> Encolar seleccionados</button>
</div>
</div>
<input type="hidden" name="current_page" value="<?php echo $page; ?>">
<input type="hidden" name="current_per_page" value="<?php echo $per_page; ?>">
</form>
<?php
$queryBase = function($targetPage) use ($per_page) {
return 'productos_bulk_update.php?page=' . $targetPage . '&per_page=' . $per_page;
};
?>
<div class="row pagination-row">
<div class="twelve columns">
<?php if($page > 1): ?>
<a class="button" href="<?php echo $queryBase($page-1); ?>">← Página anterior</a>
<?php else: ?>
<span class="button disabled">← Página anterior</span>
<?php endif; ?>
<span class="pagination-meta">Página <?php echo $page; ?> de <?php echo $total_pages; ?></span>
<?php if($page < $total_pages): ?>
<a class="button" href="<?php echo $queryBase($page+1); ?>">Página siguiente →</a>
<?php else: ?>
<span class="button disabled">Página siguiente →</span>
<?php endif; ?>
</div>
</div>
</div>
<?php include('./inc/footer.php'); ?>

View File

@@ -0,0 +1,284 @@
<?php
/* ============================
productos_modificados.php
============================ */
require_once __DIR__ . '/bootstrap.php';
$LANG_ES = (int) legacy_config('store.language_es', 4);
$LANG_EN = (int) legacy_config('store.language_en', 1);
$IMG_BASE = legacy_config('store.image_base_url', 'https://example.local/image/');
$PRODUCT_BASE = legacy_config('store.product_base_url', 'https://example.local/index.php?route=product/product&product_id=');
/* ---- Conexión BD ---- */
$db = legacy_new_mysqli();
if ($db->connect_errno) {
header('Content-Type: text/plain; charset=utf-8');
http_response_code(500);
echo "DB Error: " . $db->connect_error;
exit;
}
/* ---- AJAX ---- */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
header('Content-Type: application/json; charset=utf-8');
// Toggle individual
if (isset($_POST['pid']) && isset($_POST['toggle'])) {
$pid = (int)$_POST['pid'];
$state = (int)($_POST['toggle'] ? 1 : 0);
$ok = $db->query("UPDATE oc_product_queue SET needs_verify=$state WHERE product_id=$pid");
echo json_encode(['ok'=>$ok, 'state'=>$state]);
exit;
}
// Auto-marcar faltantes
if (isset($_POST['autoMark'])) {
$ok = $db->query("UPDATE oc_product_queue
SET needs_verify=1
WHERE processed=1 AND
(product_id IN (
SELECT p.product_id FROM oc_product p
LEFT JOIN oc_product_description d1 ON p.product_id=d1.product_id AND d1.language_id=$LANG_ES
LEFT JOIN oc_product_description d2 ON p.product_id=d2.product_id AND d2.language_id=$LANG_EN
WHERE (d1.description IS NULL OR d1.description='')
OR (d2.description IS NULL OR d2.description='')
))");
echo json_encode(['ok'=>$ok]);
exit;
}
// 🔁 Reprocesar Needs Verify
if (isset($_POST['reprocessNeeds'])) {
$ok = $db->query("UPDATE oc_product_queue
SET processed=0, log=NULL
WHERE needs_verify=1");
echo json_encode(['ok'=>$ok]);
exit;
}
}
/* ---- Vista ---- */
include('./inc/header.php'); // aquí ya se incluye custom.css
/* ---- Paginación ---- */
$per_page = 50;
$page = isset($_GET['page']) ? max(1, intval($_GET['page'])) : 1;
$offset = ($page - 1) * $per_page;
$search = isset($_GET['q']) ? trim($_GET['q']) : '';
$filter_missing = isset($_GET['missing']) ? (int)$_GET['missing'] : 0;
$conditions = ["q.processed=1"];
if ($search !== '') {
$safe = $db->real_escape_string($search);
$conditions[] = "(d1.name LIKE '%$safe%' OR d2.name LIKE '%$safe%')";
}
if ($filter_missing) {
$conditions[] = "(
(d1.description IS NULL OR d1.description='')
OR (d2.description IS NULL OR d2.description='')
OR (d1.meta_description IS NULL OR d1.meta_description='')
OR (d2.meta_description IS NULL OR d2.meta_description='')
)";
}
$where = implode(' AND ', $conditions);
$res_total = $db->query("SELECT COUNT(*) AS t
FROM oc_product_queue q
JOIN oc_product p ON q.product_id=p.product_id
LEFT JOIN oc_product_description d1 ON p.product_id=d1.product_id AND d1.language_id=$LANG_ES
LEFT JOIN oc_product_description d2 ON p.product_id=d2.product_id AND d2.language_id=$LANG_EN
WHERE $where");
$total = $res_total ? (int)$res_total->fetch_assoc()['t'] : 0;
$total_pages = max(1, ceil($total / $per_page));
$sql = "
SELECT p.product_id,p.image,
d1.description AS descripcion_es,
d2.description AS descripcion_en,
d1.name AS nombre_es,
d2.name AS nombre_en,
q.processed_at,q.needs_verify
FROM oc_product_queue q
JOIN oc_product p ON q.product_id=p.product_id
LEFT JOIN oc_product_description d1 ON p.product_id=d1.product_id AND d1.language_id=$LANG_ES
LEFT JOIN oc_product_description d2 ON p.product_id=d2.product_id AND d2.language_id=$LANG_EN
WHERE $where
ORDER BY q.processed_at DESC
LIMIT $per_page OFFSET $offset";
$res = $db->query($sql);
?>
<div class="container">
<h3>🧾 Productos modificados</h3>
<p>Total: <b><?php echo $total; ?></b> — Página <b><?php echo $page; ?></b> de <b><?php echo $total_pages; ?></b></p>
<form method="GET">
<div class="row" style="margin-bottom: 0;">
<div class="nine columns">
<input type="text"
id="search-name"
name="q"
value="<?php echo htmlspecialchars($search); ?>"
placeholder="Filtrar por nombre..."
class="u-full-width">
</div>
<div class="three columns">
<label for="missing" class="u-pull-right" style="margin-top:8px;">
<input type="checkbox"
id="missing"
name="missing"
value="1"
<?php echo $filter_missing ? 'checked' : ''; ?>>
Missing
</label>
</div>
</div>
<div class="row">
<div class="six columns">
<button type="submit" class="button button-primary u-full-width">Aplicar</button>
</div>
<div class="six columns">
<a href="productos_modificados.php" class="button u-full-width">Limpiar</a>
</div>
</div>
</form>
<div class="row toolbar">
<div class="six columns">
<button class="button u-full-width" onclick="autoMark()" type="button">⚙️ Auto-marcar faltantes</button>
</div>
<div class="six columns">
<button class="button u-full-width" onclick="reprocessNeeds()" type="button">🔁 Reprocesar Needs Verify</button>
</div>
</div>
<table class="u-full-width">
<tr><th>Imagen</th><th>Descripciones</th></tr>
<?php while($r=$res->fetch_assoc()):
$img = !empty($r['image']) ? $IMG_BASE.htmlspecialchars($r['image']) : "https://via.placeholder.com/160";
$url = $PRODUCT_BASE.$r['product_id'];
$fecha = $r['processed_at']?date('d/m/Y H:i',strtotime($r['processed_at'])):'-';
$has_en = !empty(trim($r['descripcion_en']));
$has_es = !empty(trim($r['descripcion_es']));
$flag_html = $r['needs_verify']
? '<span class="badge warn">⚠️ Needs verify</span>'
: '<span class="badge ok">✔ Verified</span>';
$btn_text = $r['needs_verify'] ? '✅ Mark OK' : '⚠️ Mark Needs Verify';
$btn_cls = $r['needs_verify'] ? 'toggle-btn active' : 'toggle-btn';
?>
<tr>
<td rowspan="2" style="text-align:center;width:180px;">
<a href="<?php echo $url;?>" target="_blank"><img src="<?php echo $img;?>" alt=""></a>
<div class="idbox">
ID <?php echo $r['product_id'];?><br>
<small><?php echo htmlspecialchars($r['nombre_es'] ?: $r['nombre_en'] ?: ''); ?></small><br>
<small><?php echo $fecha;?></small><br>
<div id="flag-<?php echo $r['product_id'];?>"><?php echo $flag_html;?></div>
<button class="<?php echo $btn_cls;?>" id="btn-<?php echo $r['product_id'];?>"
onclick="toggleVerify(<?php echo $r['product_id'];?>)">
<?php echo $btn_text;?>
</button>
</div>
</td>
<td>
<div class="lang-title">🇬🇧 English
<span class="badge <?php echo $has_en?'ok':'miss';?>"><?php echo $has_en?'OK':'Missing';?></span>
</div>
<div class="lang-section"><?php echo $has_en?$r['descripcion_en']:'<i>No data</i>';?></div>
</td>
</tr>
<tr>
<td>
<div class="lang-title">🇪🇸 Español
<span class="badge <?php echo $has_es?'ok':'miss';?>"><?php echo $has_es?'OK':'Missing';?></span>
</div>
<div class="lang-section"><?php echo $has_es?$r['descripcion_es']:'<i>No data</i>';?></div>
</td>
</tr>
<?php endwhile;?>
</table>
<?php
$queryBase = function($targetPage) use ($search, $filter_missing) {
$params = ['page'=>$targetPage];
if ($search !== '') $params['q'] = $search;
if ($filter_missing) $params['missing'] = 1;
return 'productos_modificados.php?' . http_build_query($params);
};
?>
<div class="pagination">
<?php if($page>1):?>
<a href="<?php echo $queryBase($page-1);?>">← Prev</a>
<?php else:?>
<a class="disabled">← Prev</a>
<?php endif;?>
<span>Página <?php echo $page;?> de <?php echo $total_pages;?></span>
<?php if($page<$total_pages):?>
<a href="<?php echo $queryBase($page+1);?>">Next →</a>
<?php else:?>
<a class="disabled">Next →</a>
<?php endif;?>
</div>
</div>
<script>
function toggleVerify(pid){
const btn = document.getElementById('btn-'+pid);
const flag = document.getElementById('flag-'+pid);
const active = btn.classList.contains('active');
const next = active ? 0 : 1;
const params = new URLSearchParams({pid:pid, toggle:next});
fetch(window.location.pathname+window.location.search,{
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:params.toString()
}).then(r=>r.json()).then(j=>{
if(!j.ok){alert('Error al actualizar');return;}
if(j.state===1){
btn.classList.add('active');
btn.textContent='✅ Mark OK';
flag.innerHTML='<span class="badge warn">⚠️ Needs verify</span>';
}else{
btn.classList.remove('active');
btn.textContent='⚠️ Mark Needs Verify';
flag.innerHTML='<span class="badge ok">✔ Verified</span>';
}
});
}
function autoMark(){
if(!confirm('¿Marcar automáticamente como "Need verification" todos los productos con descripciones faltantes?'))return;
fetch('',{
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'autoMark=1'
}).then(r=>r.json()).then(j=>{
if(j.ok){alert('Productos con descripciones faltantes marcados como Need Verify.');location.reload();}
else alert('Error al marcar.');
});
}
function reprocessNeeds(){
if(!confirm('¿Reprocesar todos los productos marcados como "Needs Verify"?'))return;
fetch('',{
method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'reprocessNeeds=1'
})
.then(r=>r.json())
.then(j=>{
if(j.ok){
alert('Los productos "Needs Verify" han sido marcados como pendientes para reprocesar. El worker los regenerará.');
location.reload();
} else alert('Error al actualizar.');
})
.catch(()=>alert('Error de red.'));
}
</script>
<?php include('./inc/footer.php'); ?>

View File

@@ -0,0 +1,255 @@
<?php
// ============================================================
// worker_bulk.php — doble prompt: inglés y español independientes
// ============================================================
require_once __DIR__ . '/bootstrap.php';
date_default_timezone_set('Europe/Madrid');
mb_internal_encoding('UTF-8');
@ini_set('max_execution_time', '0');
@set_time_limit(0);
/* === CONFIG === */
$OPENAI_API_KEY = trim((string) legacy_config('openai.api_key', ''));
$OPENAI_MODEL = legacy_config('openai.model', 'gpt-4o-mini');
$OPENAI_ENDPOINT = legacy_config('openai.endpoint', 'https://api.openai.com/v1/chat/completions');
$LANG_ES = (int) legacy_config('store.language_es', 4);
$LANG_EN = (int) legacy_config('store.language_en', 1);
$STORE_NAME = legacy_config('store.name', 'Natural - Mercado de Vida');
$LOG_FILE = legacy_config('paths.worker_log', __DIR__ . '/logs/worker.log');
$PROMPT_EN_FILE = legacy_config('paths.prompt_en', __DIR__ . '/inc/prompt_en.md');
$PROMPT_ES_FILE = legacy_config('paths.prompt_es', __DIR__ . '/inc/prompt_es.md');
$BATCH_SIZE = (int) legacy_config('worker.batch_size', 20);
$MIN_HTML_LENGTH = (int) legacy_config('worker.min_html_length', 500);
$SHARD_TOTAL = 1;
$SHARD_INDEX = 0;
if (PHP_SAPI === 'cli' && isset($argv)) {
foreach ($argv as $arg) {
if (strpos($arg, '--shards=') === 0) {
$value = (int)substr($arg, 9);
if ($value > 0) $SHARD_TOTAL = min($value, 16); // evita saturar en exceso
} elseif (strpos($arg, '--shard=') === 0) {
$value = (int)substr($arg, 8);
if ($value >= 0) $SHARD_INDEX = $value;
}
}
}
if ($SHARD_INDEX >= $SHARD_TOTAL) {
$SHARD_INDEX = $SHARD_TOTAL - 1;
}
if ($SHARD_INDEX < 0) $SHARD_INDEX = 0;
/* === FUNCIONES === */
function log_msg($msg) {
global $LOG_FILE;
$time = date('Y-m-d H:i:s');
file_put_contents($LOG_FILE, "[$time] $msg\n", FILE_APPEND);
}
function obtener_respuesta($prompt, $key, $model, $max_tokens = 2000, $retries = 3) {
$endpoint = legacy_config('openai.endpoint', 'https://api.openai.com/v1/chat/completions');
if ($key === '' || strpos($key, 'CHANGE_ME_') === 0) {
log_msg('❌ Missing openai.api_key in config/local.php');
return '';
}
for ($i = 1; $i <= $retries; $i++) {
$ch = curl_init($endpoint);
$data = [
'model' => $model,
'messages' => [['role' => 'user', 'content' => $prompt]],
'temperature' => 0.6,
'max_tokens' => $max_tokens
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . trim($key)
],
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_TIMEOUT => 180
]);
$result = curl_exec($ch);
$err = curl_error($ch);
$http = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($err) { log_msg("⚠️ cURL error ($i/$retries): $err"); sleep(2); continue; }
if ($http !== 200) { log_msg("⚠️ HTTP $http on attempt $i"); sleep(3); continue; }
$json = json_decode($result, true);
$txt = $json['choices'][0]['message']['content'] ?? '';
if ($txt && mb_strlen(trim($txt)) > 50) return trim($txt);
log_msg("⚠️ Empty response attempt $i");
sleep(2);
}
log_msg("❌ No response after $retries attempts");
return '';
}
function limpiar_html($t) {
if (!$t) return '';
// 🔧 Quita fences Markdown (```html ... ```)
$t = preg_replace('/^```[a-zA-Z]*\s*/m', '', $t);
$t = preg_replace('/```$/m', '', $t);
$t = preg_replace('/```[\s\S]*?```/', '', $t);
// Quita h1/h2 pero conserva contenido
$t = preg_replace('/<\/?h1[^>]*>/i', '', $t);
$t = preg_replace('/<\/?h2[^>]*>/i', '', $t);
// Convierte div y section a <p>
$t = preg_replace('/<\s*div[^>]*>/i', '<p>', $t);
$t = preg_replace('/<\s*\/div\s*>/i', '</p>', $t);
$t = preg_replace('/<\s*section[^>]*>/i', '<p>', $t);
$t = preg_replace('/<\s*\/section\s*>/i', '</p>', $t);
// Quita scripts y estilos
$t = preg_replace('/<script.*?<\/script>/is', '', $t);
$t = preg_replace('/<style.*?<\/style>/is', '', $t);
// Quita markdown residual
$t = str_replace('```', '', $t);
// Limpieza de espacios
$t = preg_replace('/[ \t]+/', ' ', $t);
$t = preg_replace('/\n{2,}/', "\n", $t);
return trim($t);
}
/* Elimina emojis y normaliza espacios */
function sanitize_for_db($text) {
if ($text === null || $text === '') return '';
$text = preg_replace('/[\x{10000}-\x{10FFFF}]/u', '', $text);
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
function sentence_case($text) {
if (empty($text)) return '';
$text = trim(mb_strtolower($text, 'UTF-8'));
$first = mb_strtoupper(mb_substr($text, 0, 1, 'UTF-8'), 'UTF-8');
return $first . mb_substr($text, 1, null, 'UTF-8');
}
/* === DB === */
$db = legacy_new_mysqli();
if ($db->connect_errno) { log_msg('❌ DB: ' . $db->connect_error); exit; }
/* === Prompt base === */
if (!file_exists($PROMPT_EN_FILE) || !file_exists($PROMPT_ES_FILE)) {
log_msg("❌ Missing prompt files.");
exit;
}
$PROMPT_EN = file_get_contents($PROMPT_EN_FILE);
$PROMPT_ES = file_get_contents($PROMPT_ES_FILE);
if (trim($PROMPT_EN) === '' || trim($PROMPT_ES) === '') {
log_msg("❌ Empty prompt files.");
exit;
}
/* === Worker === */
$shardLabel = $SHARD_TOTAL > 1 ? " | shard {$SHARD_INDEX}/{$SHARD_TOTAL}" : '';
log_msg("🚀 Worker iniciado (modo doble prompt, batch={$BATCH_SIZE}{$shardLabel})");
$shardFilter = $SHARD_TOTAL > 1 ? " AND MOD(id, {$SHARD_TOTAL}) = {$SHARD_INDEX}" : '';
$q = $db->query("SELECT * FROM oc_product_queue WHERE processed=0{$shardFilter} ORDER BY id ASC LIMIT $BATCH_SIZE");
if (!$q || $q->num_rows === 0) { log_msg("⏸️ Cola vacía."); exit; }
while ($row = $q->fetch_assoc()) {
$pid = (int)$row['product_id'];
log_msg("🔄 Procesando producto $pid...");
$r = $db->query("
SELECT p.ean, d.name
FROM oc_product p
LEFT JOIN oc_product_description d ON p.product_id=d.product_id AND d.language_id=$LANG_ES
WHERE p.product_id=$pid
");
if (!$r || !$prod = $r->fetch_assoc()) {
log_msg("⚠️ Producto $pid no encontrado");
$db->query("UPDATE oc_product_queue SET processed=1, log='No encontrado' WHERE product_id=$pid");
continue;
}
$producto = $prod['name'];
$ean = $prod['ean'];
// === Prompts personalizados ===
$prompt_en = str_replace(['$producto', '$ean'], [$producto, $ean], $PROMPT_EN);
$prompt_es = str_replace(['$producto', '$ean'], [$producto, $ean], $PROMPT_ES);
// === Generar EN ===
$raw_en = obtener_respuesta($prompt_en, $OPENAI_API_KEY, $OPENAI_MODEL, 2200);
file_put_contents(__DIR__ . "/logs/raw_openai_en_$pid.txt", $raw_en);
$clean_en = limpiar_html($raw_en);
$html_en = sanitize_for_db($clean_en);
$meta_en = sanitize_for_db(mb_substr(strip_tags($clean_en), 0, 255, 'UTF-8'));
// === Generar ES ===
$raw_es = obtener_respuesta($prompt_es, $OPENAI_API_KEY, $OPENAI_MODEL, 2200);
file_put_contents(__DIR__ . "/logs/raw_openai_es_$pid.txt", $raw_es);
$clean_es = limpiar_html($raw_es);
$html_es = sanitize_for_db($clean_es);
$meta_es = sanitize_for_db(mb_substr(strip_tags($clean_es), 0, 255, 'UTF-8'));
// === Longitud de contenido ===
$len_en = mb_strlen($html_en);
$len_es = mb_strlen($html_es);
file_put_contents(__DIR__ . "/logs/html_debug_$pid.txt",
"EN ($len_en):\n$html_en\n\nES ($len_es):\n$html_es"
);
if ($len_en < $MIN_HTML_LENGTH || $len_es < $MIN_HTML_LENGTH) {
log_msg("❌ Texto demasiado corto (EN=$len_en / ES=$len_es) PID $pid");
$db->query("UPDATE oc_product_queue
SET processed=1, processed_at=NOW(), result_en=0, result_es=0, needs_verify=1, log='Texto corto'
WHERE product_id=$pid");
continue;
}
// === Guardar ===
$u_title_en = sentence_case("$producto | $STORE_NAME");
$u_h1_en = $producto;
$u_h2_en = sentence_case("benefits and properties of $producto");
$u_title_es = sentence_case("comprar $producto | $STORE_NAME");
$u_h1_es = $producto;
$u_h2_es = sentence_case("propiedades y beneficios de $producto");
$stmt = $db->prepare("UPDATE oc_product_description
SET description=?, meta_description=?, u_title=?, u_h1=?, u_h2=?
WHERE product_id=? AND language_id=?");
$stmt->bind_param('ssssssi', $html_en, $meta_en, $u_title_en, $u_h1_en, $u_h2_en, $pid, $LANG_EN);
if (!$stmt->execute()) log_msg("❌ Error EN $pid: " . $stmt->error);
$stmt->close();
$stmt = $db->prepare("UPDATE oc_product_description
SET description=?, meta_description=?, u_title=?, u_h1=?, u_h2=?
WHERE product_id=? AND language_id=?");
$stmt->bind_param('ssssssi', $html_es, $meta_es, $u_title_es, $u_h1_es, $u_h2_es, $pid, $LANG_ES);
if (!$stmt->execute()) log_msg("❌ Error ES $pid: " . $stmt->error);
$stmt->close();
$db->query("UPDATE oc_product_queue
SET processed=1, processed_at=NOW(), result_en=1, result_es=1, needs_verify=0, log='OK doble prompt'
WHERE product_id=$pid");
log_msg("$pid completado EN/ES (len EN=$len_en | ES=$len_es)");
usleep(100000);
}
log_msg("🏁 Worker finalizado.");