first commit

This commit is contained in:
gustavooth
2026-07-23 20:32:08 -03:00
commit 2df14d57dd
62 changed files with 9780 additions and 0 deletions
@@ -0,0 +1,90 @@
<?php
/**
* Plugin Name: Gustavo Portfolio Core
* Plugin URI: https://gustavoo.me/
* Description: Conteúdo, configurações e integrações do portfólio de Gustavo Oliveira.
* Version: 1.6.0
* Requires at least: 6.5
* Requires PHP: 8.0
* Requires Plugins: fluentform, fluent-crm, fluent-smtp
* Author: Gustavo Oliveira
* Author URI: https://gustavoo.me/
* Text Domain: gustavoo-portfolio-core
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
define( 'GSO_PORTFOLIO_CORE_VERSION', '1.6.0' );
define( 'GSO_PORTFOLIO_CORE_FILE', __FILE__ );
define( 'GSO_PORTFOLIO_CORE_DIR', plugin_dir_path( __FILE__ ) );
require_once GSO_PORTFOLIO_CORE_DIR . 'includes/class-gso-projects.php';
require_once GSO_PORTFOLIO_CORE_DIR . 'includes/class-gso-portfolio-settings.php';
require_once GSO_PORTFOLIO_CORE_DIR . 'includes/class-gso-newsletter-widget.php';
require_once GSO_PORTFOLIO_CORE_DIR . 'includes/class-gso-seeder.php';
require_once GSO_PORTFOLIO_CORE_DIR . 'includes/class-gso-portfolio-core.php';
register_activation_hook( __FILE__, array( 'GSO_Portfolio_Core', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'GSO_Portfolio_Core', 'deactivate' ) );
GSO_Portfolio_Core::init();
if ( ! function_exists( 'gso_portfolio_get_settings' ) ) {
/**
* Return all portfolio settings merged with defaults.
*
* @return array<string, mixed>
*/
function gso_portfolio_get_settings() {
return GSO_Portfolio_Settings::get_settings();
}
}
if ( ! function_exists( 'gso_portfolio_get_setting' ) ) {
/**
* Return one portfolio setting.
*
* @param string $key Setting key.
* @param mixed $default Fallback value.
* @return mixed
*/
function gso_portfolio_get_setting( $key, $default = null ) {
$settings = gso_portfolio_get_settings();
return array_key_exists( $key, $settings ) ? $settings[ $key ] : $default;
}
}
if ( ! function_exists( 'gso_render_fluent_form' ) ) {
/**
* Render a Fluent Form using a trusted numeric ID.
*
* @param int $form_id Fluent Forms form ID.
* @return string
*/
function gso_render_fluent_form( $form_id ) {
$form_id = absint( $form_id );
if ( ! $form_id || ! shortcode_exists( 'fluentform' ) ) {
return '';
}
return do_shortcode( sprintf( '[fluentform id="%d"]', $form_id ) );
}
}
if ( ! function_exists( 'gso_portfolio_render_fluent_form' ) ) {
/**
* Backwards-compatible descriptive alias for the form helper.
*
* @param int $form_id Fluent Forms form ID.
* @return string
*/
function gso_portfolio_render_fluent_form( $form_id ) {
return gso_render_fluent_form( $form_id );
}
}
@@ -0,0 +1,110 @@
<?php
/**
* Newsletter widget powered by Fluent Forms and FluentCRM.
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
/**
* Keeps the newsletter CTA available even if the presentation theme changes.
*/
final class GSO_Newsletter_Widget extends WP_Widget {
/**
* Register the widget.
*/
public function __construct() {
parent::__construct(
'gso_newsletter_widget',
__( 'Gustavo — Newsletter (FluentCRM)', 'gustavoo-portfolio-core' ),
array(
'classname' => 'widget_gso_newsletter',
'description' => __( 'Exibe o formulário global de newsletter configurado no Portfólio.', 'gustavoo-portfolio-core' ),
'customize_selective_refresh' => true,
)
);
}
/**
* Render the widget.
*
* @param array<string, string> $args Sidebar wrappers.
* @param array<string, mixed> $instance Saved settings.
* @return void
*/
public function widget( $args, $instance ) {
$settings = GSO_Portfolio_Settings::get_settings();
$title = ! empty( $instance['title'] ) ? (string) $instance['title'] : (string) $settings['newsletter_heading'];
$text = ! empty( $instance['text'] ) ? (string) $instance['text'] : (string) $settings['newsletter_text'];
$form_id = ! empty( $instance['form_id'] ) ? absint( $instance['form_id'] ) : absint( $settings['newsletter_form_id'] );
echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Registered sidebar wrapper.
if ( $title ) {
echo $args['before_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Registered sidebar wrapper.
echo esc_html( $title );
echo $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Registered sidebar wrapper.
}
if ( $text ) {
printf( '<p class="widget_gso_newsletter__text">%s</p>', esc_html( $text ) );
}
$form = gso_render_fluent_form( $form_id );
if ( $form ) {
echo $form; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Output is generated by Fluent Forms.
} elseif ( current_user_can( 'manage_options' ) ) {
printf(
'<p><a href="%1$s">%2$s</a></p>',
esc_url( admin_url( 'edit.php?post_type=' . GSO_Projects::POST_TYPE . '&page=' . GSO_Portfolio_Settings::PAGE_SLUG ) ),
esc_html__( 'Configure o formulário de newsletter.', 'gustavoo-portfolio-core' )
);
}
echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Registered sidebar wrapper.
}
/**
* Render widget settings.
*
* @param array<string, mixed> $instance Saved settings.
* @return void
*/
public function form( $instance ) {
$title = isset( $instance['title'] ) ? (string) $instance['title'] : '';
$text = isset( $instance['text'] ) ? (string) $instance['text'] : '';
$form_id = isset( $instance['form_id'] ) ? absint( $instance['form_id'] ) : 0;
?>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>"><?php esc_html_e( 'Título:', 'gustavoo-portfolio-core' ); ?></label>
<input class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'title' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'title' ) ); ?>" type="text" value="<?php echo esc_attr( $title ); ?>">
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>"><?php esc_html_e( 'Texto:', 'gustavoo-portfolio-core' ); ?></label>
<textarea class="widefat" id="<?php echo esc_attr( $this->get_field_id( 'text' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'text' ) ); ?>" rows="4"><?php echo esc_textarea( $text ); ?></textarea>
</p>
<p>
<label for="<?php echo esc_attr( $this->get_field_id( 'form_id' ) ); ?>"><?php esc_html_e( 'ID do Fluent Forms (opcional):', 'gustavoo-portfolio-core' ); ?></label>
<input class="tiny-text" id="<?php echo esc_attr( $this->get_field_id( 'form_id' ) ); ?>" name="<?php echo esc_attr( $this->get_field_name( 'form_id' ) ); ?>" type="number" min="0" step="1" value="<?php echo esc_attr( (string) $form_id ); ?>">
</p>
<?php
}
/**
* Sanitize widget settings.
*
* @param array<string, mixed> $new_instance New values.
* @param array<string, mixed> $old_instance Previous values.
* @return array<string, mixed>
*/
public function update( $new_instance, $old_instance ) {
unset( $old_instance );
return array(
'title' => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',
'text' => isset( $new_instance['text'] ) ? sanitize_textarea_field( $new_instance['text'] ) : '',
'form_id' => isset( $new_instance['form_id'] ) ? absint( $new_instance['form_id'] ) : 0,
);
}
}
@@ -0,0 +1,89 @@
<?php
/**
* Main plugin coordinator.
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
/**
* Coordinates the plugin modules.
*/
final class GSO_Portfolio_Core {
/**
* Whether hooks have already been registered.
*
* @var bool
*/
private static $initialized = false;
/**
* Register plugin hooks.
*
* @return void
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'plugins_loaded', array( __CLASS__, 'load_textdomain' ) );
GSO_Projects::init();
GSO_Portfolio_Settings::init();
GSO_Seeder::init();
add_action( 'widgets_init', array( __CLASS__, 'register_widgets' ) );
}
/**
* Load translations.
*
* @return void
*/
public static function load_textdomain() {
load_plugin_textdomain(
'gustavoo-portfolio-core',
false,
dirname( plugin_basename( GSO_PORTFOLIO_CORE_FILE ) ) . '/languages'
);
}
/**
* Register bundled widgets.
*
* @return void
*/
public static function register_widgets() {
if ( class_exists( 'Gustavao_Portfolio_Newsletter_Widget' ) ) {
return;
}
register_widget( 'GSO_Newsletter_Widget' );
}
/**
* Run activation tasks.
*
* @return void
*/
public static function activate() {
GSO_Projects::register_content_types();
GSO_Portfolio_Settings::ensure_defaults();
GSO_Seeder::activate();
flush_rewrite_rules();
}
/**
* Refresh rewrite rules without deleting content or settings.
*
* @return void
*/
public static function deactivate() {
flush_rewrite_rules();
}
}
@@ -0,0 +1,459 @@
<?php
/**
* Portfolio settings screen and option contract.
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
/**
* Owns the shared option consumed by the portfolio theme.
*/
final class GSO_Portfolio_Settings {
const OPTION_NAME = 'gustavoo_portfolio_settings';
const OPTION_GROUP = 'gustavoo_portfolio_settings_group';
const PAGE_SLUG = 'gso-portfolio-settings';
/**
* Register hooks.
*
* @return void
*/
public static function init() {
add_action( 'admin_menu', array( __CLASS__, 'add_settings_page' ), 20 );
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_filter( 'plugin_action_links_' . plugin_basename( GSO_PORTFOLIO_CORE_FILE ), array( __CLASS__, 'plugin_action_links' ) );
}
/**
* Defaults based on the existing gustavoo.me content.
*
* @return array<string, mixed>
*/
public static function defaults() {
return array(
'hero_eyebrow' => __( 'Desenvolvimento de ponta a ponta', 'gustavoo-portfolio-core' ),
'hero_title' => __( 'Transformo ideias em produtos digitais sólidos.', 'gustavoo-portfolio-core' ),
'hero_description' => __( 'Gustavo, Desenvolvedor Full Stack Sênior. Web, mobile, infraestrutura e automação para tirar projetos do papel e fazê-los crescer.', 'gustavoo-portfolio-core' ),
'hero_primary_label' => __( 'Ver projetos', 'gustavoo-portfolio-core' ),
'hero_primary_url' => '#projetos',
'hero_secondary_label' => __( 'Fale comigo', 'gustavoo-portfolio-core' ),
'hero_secondary_url' => '#contato',
'about_title' => __( 'Responsabilidade técnica do planejamento à produção.', 'gustavoo-portfolio-core' ),
'about_text' => __( 'Mais do que escrever código, meu objetivo é garantir que seu projeto saia do papel e funcione exatamente como planejado. Assumo a responsabilidade técnica de ponta a ponta para que você foque no que importa: crescer.', 'gustavoo-portfolio-core' ),
'about_secondary_text' => __( 'Já participei do desenvolvimento e lançamento de múltiplos projetos digitais, atuando da arquitetura técnica à divulgação e ao crescimento das plataformas. Essa combinação de visão técnica e entendimento de mercado orienta cada entrega.', 'gustavoo-portfolio-core' ),
'contact_heading' => __( 'Tem um projeto em mente?', 'gustavoo-portfolio-core' ),
'contact_text' => __( 'Conte o que você precisa construir ou melhorar. Responderei com os próximos passos para transformar a ideia em uma entrega concreta.', 'gustavoo-portfolio-core' ),
'contact_form_id' => 0,
'newsletter_heading' => __( 'Ideias úteis, direto na sua caixa de entrada.', 'gustavoo-portfolio-core' ),
'newsletter_text' => __( 'Receba conteúdos sobre desenvolvimento, produto, infraestrutura e automação. Sem ruído e sem spam.', 'gustavoo-portfolio-core' ),
'newsletter_form_id' => 0,
'projects_title' => __( 'Projetos selecionados', 'gustavoo-portfolio-core' ),
'projects_text' => __( 'Uma seleção de produtos, experiências e soluções que ajudei a colocar no mundo.', 'gustavoo-portfolio-core' ),
'projects_limit' => 6,
'blog_title' => __( 'Código, produto e bastidores', 'gustavoo-portfolio-core' ),
'blog_text' => __( 'Análises práticas sobre desenvolvimento, infraestrutura, automação e crescimento de produtos digitais.', 'gustavoo-portfolio-core' ),
'blog_limit' => 4,
'email' => 'contato@gustavoo.me',
'whatsapp' => '+55 31 99516-8069',
'whatsapp_message' => __( 'Olá! Gostaria de falar sobre um projeto.', 'gustavoo-portfolio-core' ),
'github_url' => 'https://github.com/gustavooth',
'linkedin_url' => '',
'instagram_url' => '',
'availability_label' => __( 'Disponível para novos projetos', 'gustavoo-portfolio-core' ),
);
}
/**
* Add missing defaults without overwriting existing values.
*
* @return void
*/
public static function ensure_defaults() {
$existing = get_option( self::OPTION_NAME, null );
if ( null === $existing || ! is_array( $existing ) ) {
add_option( self::OPTION_NAME, self::defaults(), '', false );
return;
}
$merged = array_merge( self::defaults(), $existing );
if ( $merged !== $existing ) {
update_option( self::OPTION_NAME, $merged, false );
}
}
/**
* Return settings merged with defaults.
*
* @return array<string, mixed>
*/
public static function get_settings() {
$settings = get_option( self::OPTION_NAME, array() );
return array_merge( self::defaults(), is_array( $settings ) ? $settings : array() );
}
/**
* Add the Settings submenu below Portfolio.
*
* @return void
*/
public static function add_settings_page() {
add_submenu_page(
'edit.php?post_type=' . GSO_Projects::POST_TYPE,
__( 'Configurações do portfólio', 'gustavoo-portfolio-core' ),
__( 'Configurações', 'gustavoo-portfolio-core' ),
'manage_options',
self::PAGE_SLUG,
array( __CLASS__, 'render_settings_page' )
);
}
/**
* Register the option, sections and fields.
*
* @return void
*/
public static function register_settings() {
register_setting(
self::OPTION_GROUP,
self::OPTION_NAME,
array(
'type' => 'array',
'description' => __( 'Configurações compartilhadas pelo tema Gustavo Portfolio.', 'gustavoo-portfolio-core' ),
'sanitize_callback' => array( __CLASS__, 'sanitize_settings' ),
'default' => self::defaults(),
)
);
$sections = self::sections();
$fields = self::fields();
foreach ( $sections as $section_id => $section ) {
add_settings_section(
$section_id,
$section['title'],
array( __CLASS__, 'render_section_description' ),
self::PAGE_SLUG,
array( 'description' => $section['description'] )
);
}
foreach ( $fields as $key => $field ) {
add_settings_field(
'gso-setting-' . str_replace( '_', '-', $key ),
$field['label'],
array( __CLASS__, 'render_field' ),
self::PAGE_SLUG,
$field['section'],
array_merge( $field, array( 'key' => $key ) )
);
}
}
/**
* Settings page section definitions.
*
* @return array<string, array<string, string>>
*/
private static function sections() {
return array(
'gso-settings-hero' => array(
'title' => __( 'Hero', 'gustavoo-portfolio-core' ),
'description' => __( 'Mensagem principal e chamadas para ação da página inicial.', 'gustavoo-portfolio-core' ),
),
'gso-settings-about' => array(
'title' => __( 'Sobre', 'gustavoo-portfolio-core' ),
'description' => __( 'Apresentação profissional exibida na seção Sobre.', 'gustavoo-portfolio-core' ),
),
'gso-settings-content' => array(
'title' => __( 'Projetos e blog', 'gustavoo-portfolio-core' ),
'description' => __( 'Títulos, textos de apoio e limites das listagens da página inicial.', 'gustavoo-portfolio-core' ),
),
'gso-settings-contact' => array(
'title' => __( 'Contato', 'gustavoo-portfolio-core' ),
'description' => __( 'Canais diretos e formulário principal do Fluent Forms.', 'gustavoo-portfolio-core' ),
),
'gso-settings-newsletter' => array(
'title' => __( 'Newsletter', 'gustavoo-portfolio-core' ),
'description' => __( 'Conteúdo e formulário usados pelo CTA e pelo widget de newsletter.', 'gustavoo-portfolio-core' ),
),
'gso-settings-social' => array(
'title' => __( 'Redes sociais', 'gustavoo-portfolio-core' ),
'description' => __( 'Perfis públicos exibidos no tema.', 'gustavoo-portfolio-core' ),
),
);
}
/**
* Settings field schema.
*
* @return array<string, array<string, mixed>>
*/
private static function fields() {
return array(
'hero_eyebrow' => self::field( 'gso-settings-hero', __( 'Sobretítulo', 'gustavoo-portfolio-core' ), 'text' ),
'hero_title' => self::field( 'gso-settings-hero', __( 'Título', 'gustavoo-portfolio-core' ), 'text' ),
'hero_description' => self::field( 'gso-settings-hero', __( 'Descrição', 'gustavoo-portfolio-core' ), 'textarea' ),
'hero_primary_label' => self::field( 'gso-settings-hero', __( 'CTA principal — texto', 'gustavoo-portfolio-core' ), 'text' ),
'hero_primary_url' => self::field( 'gso-settings-hero', __( 'CTA principal — destino', 'gustavoo-portfolio-core' ), 'link', __( 'Aceita URL HTTP(S), mailto:, tel:, caminho relativo ou âncora como #projetos.', 'gustavoo-portfolio-core' ) ),
'hero_secondary_label' => self::field( 'gso-settings-hero', __( 'CTA secundário — texto', 'gustavoo-portfolio-core' ), 'text' ),
'hero_secondary_url' => self::field( 'gso-settings-hero', __( 'CTA secundário — destino', 'gustavoo-portfolio-core' ), 'link', __( 'Aceita URL HTTP(S), mailto:, tel:, caminho relativo ou âncora.', 'gustavoo-portfolio-core' ) ),
'about_title' => self::field( 'gso-settings-about', __( 'Título', 'gustavoo-portfolio-core' ), 'text' ),
'about_text' => self::field( 'gso-settings-about', __( 'Texto', 'gustavoo-portfolio-core' ), 'textarea' ),
'projects_title' => self::field( 'gso-settings-content', __( 'Projetos — título', 'gustavoo-portfolio-core' ), 'text' ),
'projects_text' => self::field( 'gso-settings-content', __( 'Projetos — texto', 'gustavoo-portfolio-core' ), 'textarea' ),
'projects_limit' => self::field( 'gso-settings-content', __( 'Projetos — quantidade', 'gustavoo-portfolio-core' ), 'number', __( 'Entre 1 e 24.', 'gustavoo-portfolio-core' ), array( 'min' => 1, 'max' => 24 ) ),
'blog_title' => self::field( 'gso-settings-content', __( 'Blog — título', 'gustavoo-portfolio-core' ), 'text' ),
'blog_text' => self::field( 'gso-settings-content', __( 'Blog — texto', 'gustavoo-portfolio-core' ), 'textarea' ),
'blog_limit' => self::field( 'gso-settings-content', __( 'Blog — quantidade', 'gustavoo-portfolio-core' ), 'number', __( 'Entre 1 e 24.', 'gustavoo-portfolio-core' ), array( 'min' => 1, 'max' => 24 ) ),
'contact_heading' => self::field( 'gso-settings-contact', __( 'Título', 'gustavoo-portfolio-core' ), 'text' ),
'contact_text' => self::field( 'gso-settings-contact', __( 'Texto', 'gustavoo-portfolio-core' ), 'textarea' ),
'contact_form_id' => self::field( 'gso-settings-contact', __( 'ID do formulário de contato', 'gustavoo-portfolio-core' ), 'number', __( 'ID numérico do Fluent Forms. O seed preenche este campo quando possível.', 'gustavoo-portfolio-core' ), array( 'min' => 0 ) ),
'email' => self::field( 'gso-settings-contact', __( 'E-mail', 'gustavoo-portfolio-core' ), 'email' ),
'whatsapp' => self::field( 'gso-settings-contact', __( 'WhatsApp', 'gustavoo-portfolio-core' ), 'tel', __( 'Inclua o DDI, por exemplo +55 31 99999-9999.', 'gustavoo-portfolio-core' ) ),
'whatsapp_message' => self::field( 'gso-settings-contact', __( 'WhatsApp — mensagem inicial', 'gustavoo-portfolio-core' ), 'textarea', __( 'Texto que será preenchido automaticamente ao abrir o botão flutuante.', 'gustavoo-portfolio-core' ) ),
'newsletter_heading' => self::field( 'gso-settings-newsletter', __( 'Título', 'gustavoo-portfolio-core' ), 'text' ),
'newsletter_text' => self::field( 'gso-settings-newsletter', __( 'Texto', 'gustavoo-portfolio-core' ), 'textarea' ),
'newsletter_form_id' => self::field( 'gso-settings-newsletter', __( 'ID do formulário de newsletter', 'gustavoo-portfolio-core' ), 'number', __( 'ID numérico do Fluent Forms. O seed preenche este campo quando possível.', 'gustavoo-portfolio-core' ), array( 'min' => 0 ) ),
'github_url' => self::field( 'gso-settings-social', __( 'GitHub', 'gustavoo-portfolio-core' ), 'url' ),
'linkedin_url' => self::field( 'gso-settings-social', __( 'LinkedIn', 'gustavoo-portfolio-core' ), 'url' ),
'instagram_url' => self::field( 'gso-settings-social', __( 'Instagram', 'gustavoo-portfolio-core' ), 'url' ),
);
}
/**
* Build one field definition.
*
* @param string $section Section ID.
* @param string $label Label.
* @param string $type Field type.
* @param string $description Help text.
* @param array<string, mixed> $extra Extra attributes.
* @return array<string, mixed>
*/
private static function field( $section, $label, $type, $description = '', $extra = array() ) {
return array_merge(
array(
'section' => $section,
'label' => $label,
'type' => $type,
'description' => $description,
),
$extra
);
}
/**
* Sanitize every documented setting and retain unknown extension keys.
*
* @param mixed $input Raw submitted option.
* @return array<string, mixed>
*/
public static function sanitize_settings( $input ) {
$current = get_option( self::OPTION_NAME, array() );
$output = array_merge( self::defaults(), is_array( $current ) ? $current : array() );
$input = is_array( $input ) ? $input : array();
foreach ( self::fields() as $key => $field ) {
$value = $input[ $key ] ?? '';
switch ( $field['type'] ) {
case 'textarea':
$output[ $key ] = sanitize_textarea_field( $value );
break;
case 'email':
$output[ $key ] = sanitize_email( $value );
break;
case 'tel':
$output[ $key ] = self::sanitize_phone( $value );
break;
case 'url':
$output[ $key ] = self::sanitize_public_url( $value );
break;
case 'link':
$output[ $key ] = self::sanitize_link( $value );
break;
case 'number':
$number = absint( $value );
$min = isset( $field['min'] ) ? absint( $field['min'] ) : 0;
$max = isset( $field['max'] ) ? absint( $field['max'] ) : PHP_INT_MAX;
$output[ $key ] = min( $max, max( $min, $number ) );
break;
default:
$output[ $key ] = sanitize_text_field( $value );
}
}
return $output;
}
/**
* Sanitize a public social URL.
*
* @param mixed $value Raw value.
* @return string
*/
private static function sanitize_public_url( $value ) {
$url = esc_url_raw( trim( (string) $value ), array( 'http', 'https' ) );
return $url && wp_http_validate_url( $url ) ? $url : '';
}
/**
* Sanitize a CTA link, including safe same-page anchors.
*
* @param mixed $value Raw value.
* @return string
*/
private static function sanitize_link( $value ) {
$value = trim( sanitize_text_field( $value ) );
if ( preg_match( '/^#[A-Za-z][A-Za-z0-9_:.\-]*$/', $value ) ) {
return $value;
}
return esc_url_raw( $value, array( 'http', 'https', 'mailto', 'tel' ) );
}
/**
* Keep common telephone punctuation and discard everything else.
*
* @param mixed $value Raw phone value.
* @return string
*/
private static function sanitize_phone( $value ) {
$value = sanitize_text_field( $value );
$value = preg_replace( '/[^0-9+()\-\s]/', '', $value );
return is_string( $value ) ? trim( $value ) : '';
}
/**
* Render a section introduction.
*
* @param array<string, mixed> $args Section arguments.
* @return void
*/
public static function render_section_description( $args ) {
if ( ! empty( $args['description'] ) ) {
echo '<p>' . esc_html( $args['description'] ) . '</p>';
}
}
/**
* Render a settings field from its schema.
*
* @param array<string, mixed> $args Field arguments.
* @return void
*/
public static function render_field( $args ) {
$settings = self::get_settings();
$key = $args['key'];
$value = $settings[ $key ] ?? '';
$id = 'gso-setting-' . str_replace( '_', '-', $key );
$name = self::OPTION_NAME . '[' . $key . ']';
if ( 'textarea' === $args['type'] ) {
printf(
'<textarea class="large-text" id="%1$s" name="%2$s" rows="4">%3$s</textarea>',
esc_attr( $id ),
esc_attr( $name ),
esc_textarea( (string) $value )
);
} else {
$html_type = in_array( $args['type'], array( 'email', 'number', 'tel', 'url' ), true ) ? $args['type'] : 'text';
$attrs = '';
if ( isset( $args['min'] ) ) {
$attrs .= ' min="' . esc_attr( (string) $args['min'] ) . '"';
}
if ( isset( $args['max'] ) ) {
$attrs .= ' max="' . esc_attr( (string) $args['max'] ) . '"';
}
printf(
'<input class="%1$s" id="%2$s" name="%3$s" type="%4$s" value="%5$s"%6$s>',
'number' === $html_type ? 'small-text' : 'regular-text',
esc_attr( $id ),
esc_attr( $name ),
esc_attr( $html_type ),
esc_attr( (string) $value ),
$attrs // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Attributes are assembled from escaped integers.
);
}
if ( ! empty( $args['description'] ) ) {
echo '<p class="description">' . esc_html( $args['description'] ) . '</p>';
}
}
/**
* Render the admin settings page.
*
* @return void
*/
public static function render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Configurações do portfólio', 'gustavoo-portfolio-core' ); ?></h1>
<p><?php esc_html_e( 'Estes valores são compartilhados com o tema e ficam preservados ao trocar a apresentação visual.', 'gustavoo-portfolio-core' ); ?></p>
<?php self::render_seed_status(); ?>
<form action="options.php" method="post">
<?php
settings_fields( self::OPTION_GROUP );
do_settings_sections( self::PAGE_SLUG );
submit_button();
?>
</form>
</div>
<?php
}
/**
* Render a concise status for optional form integrations.
*
* @return void
*/
private static function render_seed_status() {
$state = GSO_Seeder::get_state();
$fluent_forms = class_exists( '\\FluentForm\\App\\Models\\Form' );
$fluent_crm = function_exists( 'FluentCrmApi' );
$contact_id = absint( $state['contact_form_id'] ?? 0 );
$newsletter_id = absint( $state['newsletter_form_id'] ?? 0 );
$feeds_installed = ! empty( $state['crm_feeds'] );
?>
<div class="notice notice-info inline">
<p><strong><?php esc_html_e( 'Integrações automáticas', 'gustavoo-portfolio-core' ); ?></strong></p>
<ul>
<li><?php echo esc_html( sprintf( 'Fluent Forms: %s', $fluent_forms ? __( 'ativo', 'gustavoo-portfolio-core' ) : __( 'inativo', 'gustavoo-portfolio-core' ) ) ); ?></li>
<li><?php echo esc_html( sprintf( 'Formulário de contato: %s', $contact_id ? '#' . $contact_id : __( 'aguardando', 'gustavoo-portfolio-core' ) ) ); ?></li>
<li><?php echo esc_html( sprintf( 'Formulário de newsletter: %s', $newsletter_id ? '#' . $newsletter_id : __( 'aguardando', 'gustavoo-portfolio-core' ) ) ); ?></li>
<li><?php echo esc_html( sprintf( 'FluentCRM: %s', $fluent_crm ? ( $feeds_installed ? __( 'feeds configurados', 'gustavoo-portfolio-core' ) : __( 'ativo; configuração pendente', 'gustavoo-portfolio-core' ) ) : __( 'inativo', 'gustavoo-portfolio-core' ) ) ); ?></li>
</ul>
</div>
<?php
}
/**
* Add a shortcut from the Plugins screen.
*
* @param array<int, string> $links Existing links.
* @return array<int, string>
*/
public static function plugin_action_links( $links ) {
$url = admin_url( 'edit.php?post_type=' . GSO_Projects::POST_TYPE . '&page=' . self::PAGE_SLUG );
array_unshift(
$links,
'<a href="' . esc_url( $url ) . '">' . esc_html__( 'Configurações', 'gustavoo-portfolio-core' ) . '</a>'
);
return $links;
}
}
@@ -0,0 +1,563 @@
<?php
/**
* Project content model and admin UI.
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
/**
* Registers the portfolio project post type, taxonomies and metadata.
*/
final class GSO_Projects {
const POST_TYPE = 'gso_project';
const TAX_TYPE = 'gso_project_type';
const TAX_TECH = 'gso_project_tech';
const META_URL = '_gso_project_url';
const META_CLIENT = '_gso_project_client';
const META_YEAR = '_gso_project_year';
const META_FEATURED = '_gso_project_featured';
const META_LINK_LABEL = '_gso_project_link_label';
const META_ACCENT = '_gso_project_accent';
/**
* Register hooks.
*
* @return void
*/
public static function init() {
add_action( 'init', array( __CLASS__, 'register_content_types' ) );
add_action( 'add_meta_boxes_' . self::POST_TYPE, array( __CLASS__, 'add_meta_boxes' ) );
add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'save_meta_box' ) );
add_filter( 'manage_' . self::POST_TYPE . '_posts_columns', array( __CLASS__, 'admin_columns' ) );
add_action( 'manage_' . self::POST_TYPE . '_posts_custom_column', array( __CLASS__, 'render_admin_column' ), 10, 2 );
add_filter( 'manage_edit-' . self::POST_TYPE . '_sortable_columns', array( __CLASS__, 'sortable_columns' ) );
add_action( 'pre_get_posts', array( __CLASS__, 'apply_admin_sorting' ) );
add_action( 'restrict_manage_posts', array( __CLASS__, 'taxonomy_filters' ) );
}
/**
* Register the post type, taxonomies and REST-aware metadata.
*
* @return void
*/
public static function register_content_types() {
register_post_type(
self::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Projetos', 'gustavoo-portfolio-core' ),
'singular_name' => __( 'Projeto', 'gustavoo-portfolio-core' ),
'menu_name' => __( 'Portfólio', 'gustavoo-portfolio-core' ),
'name_admin_bar' => __( 'Projeto', 'gustavoo-portfolio-core' ),
'add_new' => __( 'Adicionar projeto', 'gustavoo-portfolio-core' ),
'add_new_item' => __( 'Adicionar novo projeto', 'gustavoo-portfolio-core' ),
'edit_item' => __( 'Editar projeto', 'gustavoo-portfolio-core' ),
'new_item' => __( 'Novo projeto', 'gustavoo-portfolio-core' ),
'view_item' => __( 'Ver projeto', 'gustavoo-portfolio-core' ),
'search_items' => __( 'Buscar projetos', 'gustavoo-portfolio-core' ),
'not_found' => __( 'Nenhum projeto encontrado.', 'gustavoo-portfolio-core' ),
'not_found_in_trash' => __( 'Nenhum projeto encontrado na lixeira.', 'gustavoo-portfolio-core' ),
'all_items' => __( 'Projetos', 'gustavoo-portfolio-core' ),
'featured_image' => __( 'Imagem do projeto', 'gustavoo-portfolio-core' ),
'set_featured_image' => __( 'Definir imagem do projeto', 'gustavoo-portfolio-core' ),
'remove_featured_image' => __( 'Remover imagem do projeto', 'gustavoo-portfolio-core' ),
),
'public' => false,
'publicly_queryable' => false,
'exclude_from_search' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_in_admin_bar' => true,
'show_in_nav_menus' => false,
'show_in_rest' => true,
'menu_position' => 25,
'menu_icon' => 'dashicons-portfolio',
'capability_type' => 'post',
'map_meta_cap' => true,
'hierarchical' => false,
'has_archive' => false,
'rewrite' => false,
'query_var' => false,
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes', 'revisions', 'custom-fields' ),
)
);
register_taxonomy(
self::TAX_TYPE,
self::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Tipos de projeto', 'gustavoo-portfolio-core' ),
'singular_name' => __( 'Tipo de projeto', 'gustavoo-portfolio-core' ),
'menu_name' => __( 'Tipos', 'gustavoo-portfolio-core' ),
'all_items' => __( 'Todos os tipos', 'gustavoo-portfolio-core' ),
'edit_item' => __( 'Editar tipo', 'gustavoo-portfolio-core' ),
'add_new_item' => __( 'Adicionar tipo', 'gustavoo-portfolio-core' ),
'search_items' => __( 'Buscar tipos', 'gustavoo-portfolio-core' ),
),
'public' => false,
'publicly_queryable' => false,
'show_ui' => true,
'show_admin_column' => false,
'show_in_rest' => true,
'hierarchical' => true,
'rewrite' => false,
)
);
register_taxonomy(
self::TAX_TECH,
self::POST_TYPE,
array(
'labels' => array(
'name' => __( 'Tecnologias', 'gustavoo-portfolio-core' ),
'singular_name' => __( 'Tecnologia', 'gustavoo-portfolio-core' ),
'menu_name' => __( 'Tecnologias', 'gustavoo-portfolio-core' ),
'all_items' => __( 'Todas as tecnologias', 'gustavoo-portfolio-core' ),
'edit_item' => __( 'Editar tecnologia', 'gustavoo-portfolio-core' ),
'add_new_item' => __( 'Adicionar tecnologia', 'gustavoo-portfolio-core' ),
'search_items' => __( 'Buscar tecnologias', 'gustavoo-portfolio-core' ),
'separate_items_with_commas' => __( 'Separe tecnologias com vírgulas', 'gustavoo-portfolio-core' ),
),
'public' => false,
'publicly_queryable' => false,
'show_ui' => true,
'show_admin_column' => false,
'show_in_rest' => true,
'hierarchical' => false,
'rewrite' => false,
)
);
self::register_meta();
}
/**
* Register project metadata.
*
* @return void
*/
private static function register_meta() {
$common = array(
'single' => true,
'show_in_rest' => true,
'auth_callback' => array( __CLASS__, 'authorize_meta' ),
);
register_post_meta(
self::POST_TYPE,
self::META_URL,
array_merge(
$common,
array(
'type' => 'string',
'sanitize_callback' => array( __CLASS__, 'sanitize_external_url' ),
)
)
);
foreach ( array( self::META_CLIENT, self::META_LINK_LABEL ) as $meta_key ) {
register_post_meta(
self::POST_TYPE,
$meta_key,
array_merge(
$common,
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
)
)
);
}
register_post_meta(
self::POST_TYPE,
self::META_YEAR,
array_merge(
$common,
array(
'type' => 'integer',
'sanitize_callback' => array( __CLASS__, 'sanitize_year' ),
)
)
);
register_post_meta(
self::POST_TYPE,
self::META_FEATURED,
array_merge(
$common,
array(
'type' => 'boolean',
'sanitize_callback' => 'rest_sanitize_boolean',
)
)
);
register_post_meta(
self::POST_TYPE,
self::META_ACCENT,
array_merge(
$common,
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_hex_color',
)
)
);
}
/**
* Restrict meta updates to users who can edit the project.
*
* @param bool $allowed Current authorization result.
* @param string $meta_key Meta key.
* @param int $post_id Project ID.
* @return bool
*/
public static function authorize_meta( $allowed, $meta_key, $post_id ) {
unset( $allowed, $meta_key );
return current_user_can( 'edit_post', (int) $post_id );
}
/**
* Allow only valid external HTTP(S) URLs.
*
* @param mixed $value Raw URL.
* @return string
*/
public static function sanitize_external_url( $value ) {
$url = esc_url_raw( trim( (string) $value ), array( 'http', 'https' ) );
return $url && wp_http_validate_url( $url ) ? $url : '';
}
/**
* Validate a four-digit project year.
*
* @param mixed $value Raw year.
* @return int
*/
public static function sanitize_year( $value ) {
$year = absint( $value );
$max_year = (int) gmdate( 'Y' ) + 5;
return $year >= 1900 && $year <= $max_year ? $year : 0;
}
/**
* Register the project details metabox.
*
* @return void
*/
public static function add_meta_boxes() {
add_meta_box(
'gso-project-details',
__( 'Detalhes do projeto', 'gustavoo-portfolio-core' ),
array( __CLASS__, 'render_meta_box' ),
self::POST_TYPE,
'normal',
'high'
);
}
/**
* Render project fields.
*
* @param WP_Post $post Current project.
* @return void
*/
public static function render_meta_box( $post ) {
$url = (string) get_post_meta( $post->ID, self::META_URL, true );
$client = (string) get_post_meta( $post->ID, self::META_CLIENT, true );
$year = absint( get_post_meta( $post->ID, self::META_YEAR, true ) );
$featured = (bool) get_post_meta( $post->ID, self::META_FEATURED, true );
$link_label = (string) get_post_meta( $post->ID, self::META_LINK_LABEL, true );
$accent = sanitize_hex_color( get_post_meta( $post->ID, self::META_ACCENT, true ) );
if ( ! $accent ) {
$accent = '#7cf3da';
}
wp_nonce_field( 'gso_save_project_details', 'gso_project_details_nonce' );
?>
<table class="form-table" role="presentation">
<tbody>
<tr>
<th scope="row"><label for="gso-project-url"><?php esc_html_e( 'URL externa', 'gustavoo-portfolio-core' ); ?></label></th>
<td>
<input class="regular-text" id="gso-project-url" name="gso_project_url" type="url" value="<?php echo esc_attr( $url ); ?>" inputmode="url" placeholder="https://">
<p class="description"><?php esc_html_e( 'Aceita somente uma URL completa usando HTTP ou HTTPS.', 'gustavoo-portfolio-core' ); ?></p>
</td>
</tr>
<tr>
<th scope="row"><label for="gso-project-client"><?php esc_html_e( 'Cliente ou marca', 'gustavoo-portfolio-core' ); ?></label></th>
<td><input class="regular-text" id="gso-project-client" name="gso_project_client" type="text" value="<?php echo esc_attr( $client ); ?>" maxlength="160"></td>
</tr>
<tr>
<th scope="row"><label for="gso-project-year"><?php esc_html_e( 'Ano', 'gustavoo-portfolio-core' ); ?></label></th>
<td><input class="small-text" id="gso-project-year" name="gso_project_year" type="number" value="<?php echo $year ? esc_attr( (string) $year ) : ''; ?>" min="1900" max="<?php echo esc_attr( (string) ( (int) gmdate( 'Y' ) + 5 ) ); ?>" step="1"></td>
</tr>
<tr>
<th scope="row"><label for="gso-project-link-label"><?php esc_html_e( 'Texto do CTA', 'gustavoo-portfolio-core' ); ?></label></th>
<td><input class="regular-text" id="gso-project-link-label" name="gso_project_link_label" type="text" value="<?php echo esc_attr( $link_label ); ?>" maxlength="80" placeholder="<?php esc_attr_e( 'Ver projeto', 'gustavoo-portfolio-core' ); ?>"></td>
</tr>
<tr>
<th scope="row"><label for="gso-project-accent"><?php esc_html_e( 'Cor de acento', 'gustavoo-portfolio-core' ); ?></label></th>
<td><input id="gso-project-accent" name="gso_project_accent" type="color" value="<?php echo esc_attr( $accent ); ?>"></td>
</tr>
<tr>
<th scope="row"><?php esc_html_e( 'Destaque', 'gustavoo-portfolio-core' ); ?></th>
<td>
<label for="gso-project-featured">
<input id="gso-project-featured" name="gso_project_featured" type="checkbox" value="1" <?php checked( $featured ); ?>>
<?php esc_html_e( 'Exibir entre os projetos em destaque', 'gustavoo-portfolio-core' ); ?>
</label>
</td>
</tr>
</tbody>
</table>
<?php
}
/**
* Save project fields after nonce and capability checks.
*
* @param int $post_id Project ID.
* @return void
*/
public static function save_meta_box( $post_id ) {
// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified immediately below.
$nonce = isset( $_POST['gso_project_details_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['gso_project_details_nonce'] ) ) : '';
if ( ! $nonce || ! wp_verify_nonce( $nonce, 'gso_save_project_details' ) ) {
return;
}
if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) ) {
return;
}
if ( self::POST_TYPE !== get_post_type( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
// phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verified above.
$url = isset( $_POST['gso_project_url'] ) ? self::sanitize_external_url( wp_unslash( $_POST['gso_project_url'] ) ) : '';
$client = isset( $_POST['gso_project_client'] ) ? sanitize_text_field( wp_unslash( $_POST['gso_project_client'] ) ) : '';
$year = isset( $_POST['gso_project_year'] ) ? self::sanitize_year( wp_unslash( $_POST['gso_project_year'] ) ) : 0;
$link_label = isset( $_POST['gso_project_link_label'] ) ? sanitize_text_field( wp_unslash( $_POST['gso_project_link_label'] ) ) : '';
$accent = isset( $_POST['gso_project_accent'] ) ? sanitize_hex_color( wp_unslash( $_POST['gso_project_accent'] ) ) : '';
$featured = isset( $_POST['gso_project_featured'] ) ? '1' : '';
// phpcs:enable WordPress.Security.NonceVerification.Missing
self::update_or_delete_meta( $post_id, self::META_URL, $url );
self::update_or_delete_meta( $post_id, self::META_CLIENT, $client );
self::update_or_delete_meta( $post_id, self::META_YEAR, $year );
self::update_or_delete_meta( $post_id, self::META_LINK_LABEL, $link_label );
self::update_or_delete_meta( $post_id, self::META_ACCENT, $accent );
self::update_or_delete_meta( $post_id, self::META_FEATURED, $featured );
}
/**
* Store a non-empty value, otherwise remove the metadata row.
*
* @param int $post_id Project ID.
* @param string $meta_key Metadata key.
* @param mixed $value Sanitized value.
* @return void
*/
private static function update_or_delete_meta( $post_id, $meta_key, $value ) {
if ( '' === $value || 0 === $value || null === $value ) {
delete_post_meta( $post_id, $meta_key );
return;
}
update_post_meta( $post_id, $meta_key, $value );
}
/**
* Define project list columns.
*
* @param array<string, string> $columns Existing columns.
* @return array<string, string>
*/
public static function admin_columns( $columns ) {
return array(
'cb' => $columns['cb'] ?? '<input type="checkbox">',
'gso_thumbnail' => __( 'Imagem', 'gustavoo-portfolio-core' ),
'title' => __( 'Projeto', 'gustavoo-portfolio-core' ),
'gso_client' => __( 'Cliente', 'gustavoo-portfolio-core' ),
'gso_type' => __( 'Tipo', 'gustavoo-portfolio-core' ),
'gso_tech' => __( 'Tecnologias', 'gustavoo-portfolio-core' ),
'gso_year' => __( 'Ano', 'gustavoo-portfolio-core' ),
'gso_featured' => __( 'Destaque', 'gustavoo-portfolio-core' ),
'gso_url' => __( 'URL', 'gustavoo-portfolio-core' ),
'gso_menu_order' => __( 'Ordem', 'gustavoo-portfolio-core' ),
'date' => $columns['date'] ?? __( 'Data', 'gustavoo-portfolio-core' ),
);
}
/**
* Render one project list cell.
*
* @param string $column Column ID.
* @param int $post_id Project ID.
* @return void
*/
public static function render_admin_column( $column, $post_id ) {
switch ( $column ) {
case 'gso_thumbnail':
if ( has_post_thumbnail( $post_id ) ) {
echo wp_kses_post( get_the_post_thumbnail( $post_id, array( 64, 48 ), array( 'style' => 'width:64px;height:48px;object-fit:cover;border-radius:4px;' ) ) );
} else {
echo '<span aria-hidden="true">—</span><span class="screen-reader-text">' . esc_html__( 'Sem imagem', 'gustavoo-portfolio-core' ) . '</span>';
}
break;
case 'gso_client':
echo esc_html( (string) get_post_meta( $post_id, self::META_CLIENT, true ) ?: '—' );
break;
case 'gso_type':
self::render_terms_column( $post_id, self::TAX_TYPE );
break;
case 'gso_tech':
self::render_terms_column( $post_id, self::TAX_TECH );
break;
case 'gso_year':
$year = absint( get_post_meta( $post_id, self::META_YEAR, true ) );
echo $year ? esc_html( (string) $year ) : '—';
break;
case 'gso_featured':
if ( get_post_meta( $post_id, self::META_FEATURED, true ) ) {
echo '<span class="dashicons dashicons-star-filled" aria-hidden="true"></span><span class="screen-reader-text">' . esc_html__( 'Sim', 'gustavoo-portfolio-core' ) . '</span>';
} else {
echo '<span aria-hidden="true">—</span><span class="screen-reader-text">' . esc_html__( 'Não', 'gustavoo-portfolio-core' ) . '</span>';
}
break;
case 'gso_url':
$url = self::sanitize_external_url( get_post_meta( $post_id, self::META_URL, true ) );
if ( $url ) {
$host = wp_parse_url( $url, PHP_URL_HOST );
printf(
'<a href="%1$s" target="_blank" rel="noopener noreferrer external">%2$s<span class="screen-reader-text"> %3$s</span></a>',
esc_url( $url ),
esc_html( $host ?: $url ),
esc_html__( '(abre em nova aba)', 'gustavoo-portfolio-core' )
);
} else {
echo '—';
}
break;
case 'gso_menu_order':
echo esc_html( (string) get_post_field( 'menu_order', $post_id ) );
break;
}
}
/**
* Render taxonomy terms in an admin column.
*
* @param int $post_id Project ID.
* @param string $taxonomy Taxonomy name.
* @return void
*/
private static function render_terms_column( $post_id, $taxonomy ) {
$terms = get_the_terms( $post_id, $taxonomy );
if ( ! $terms || is_wp_error( $terms ) ) {
echo '—';
return;
}
echo esc_html( implode( ', ', wp_list_pluck( $terms, 'name' ) ) );
}
/**
* Make selected columns sortable.
*
* @param array<string, string> $columns Existing sortable columns.
* @return array<string, string>
*/
public static function sortable_columns( $columns ) {
$columns['gso_client'] = 'gso_client';
$columns['gso_year'] = 'gso_year';
$columns['gso_featured'] = 'gso_featured';
$columns['gso_menu_order'] = 'menu_order';
return $columns;
}
/**
* Apply safe sorting to the main project admin query.
*
* @param WP_Query $query Current query.
* @return void
*/
public static function apply_admin_sorting( $query ) {
if ( ! is_admin() || ! $query->is_main_query() || self::POST_TYPE !== $query->get( 'post_type' ) ) {
return;
}
switch ( $query->get( 'orderby' ) ) {
case 'gso_client':
$query->set( 'meta_key', self::META_CLIENT );
$query->set( 'orderby', 'meta_value' );
break;
case 'gso_year':
$query->set( 'meta_key', self::META_YEAR );
$query->set( 'orderby', 'meta_value_num' );
break;
case 'gso_featured':
$query->set( 'meta_key', self::META_FEATURED );
$query->set( 'orderby', 'meta_value_num' );
break;
case 'menu_order':
$query->set( 'orderby', 'menu_order title' );
break;
}
}
/**
* Add type and technology filters above the project list.
*
* @param string $post_type Current list post type.
* @return void
*/
public static function taxonomy_filters( $post_type ) {
if ( self::POST_TYPE !== $post_type ) {
return;
}
foreach ( array( self::TAX_TYPE, self::TAX_TECH ) as $taxonomy ) {
$tax_object = get_taxonomy( $taxonomy );
if ( ! $tax_object ) {
continue;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only list filter.
$selected = isset( $_GET[ $taxonomy ] ) ? sanitize_title( wp_unslash( $_GET[ $taxonomy ] ) ) : '';
wp_dropdown_categories(
array(
'show_option_all' => sprintf(
/* translators: %s: taxonomy label. */
__( 'Todos: %s', 'gustavoo-portfolio-core' ),
$tax_object->labels->name
),
'taxonomy' => $taxonomy,
'name' => $taxonomy,
'orderby' => 'name',
'selected' => $selected,
'hide_empty' => false,
'hierarchical' => $tax_object->hierarchical,
'value_field' => 'slug',
)
);
}
}
}
@@ -0,0 +1,619 @@
<?php
/**
* Idempotent starter content and Fluent Forms integration.
*
* @package GustavoPortfolioCore
*/
defined( 'ABSPATH' ) || exit;
/**
* Creates useful starter content once, without overwriting later admin edits.
*/
final class GSO_Seeder {
const VERSION = '1.6.0';
const STATE_OPTION = 'gso_portfolio_seed_state';
const LOCK_OPTION = 'gso_portfolio_seed_lock';
const SITE_OPTION = 'gso_portfolio_site_state';
const PROJECT_META = '_gso_seed_key';
const FORM_META = '_gustavoo_seed_key';
const FEED_META = '_gustavoo_seed_fluentcrm_feed_id';
/**
* Register deferred integration setup.
*
* @return void
*/
public static function init() {
add_action( 'init', array( __CLASS__, 'maybe_seed' ), 20 );
add_action( 'after_switch_theme', array( __CLASS__, 'maybe_seed' ), 20 );
}
/**
* Seed content available during activation. Fluent integrations run on init.
*
* @return void
*/
public static function activate() {
self::seed_site_configuration();
self::seed_projects();
$state = self::get_state();
$state['projects_seeded'] = true;
$state['status'] = 'pending';
update_option( self::STATE_OPTION, $state, false );
}
/**
* Return a stable status contract for the admin screen.
*
* @return array<string, mixed>
*/
public static function get_state() {
$state = get_option( self::STATE_OPTION, array() );
return wp_parse_args(
is_array( $state ) ? $state : array(),
array(
'version' => '',
'status' => 'pending',
'projects_seeded' => false,
'contact_form_id' => 0,
'newsletter_form_id' => 0,
'crm_feeds' => false,
'message' => '',
)
);
}
/**
* Complete integration setup when Fluent Forms and FluentCRM are ready.
*
* @return void
*/
public static function maybe_seed() {
self::seed_site_configuration();
$state = self::get_state();
if ( self::VERSION === $state['version'] && 'complete' === $state['status'] ) {
self::maybe_migrate_contact_form();
return;
}
if ( ! post_type_exists( GSO_Projects::POST_TYPE ) ) {
GSO_Projects::register_content_types();
}
self::seed_projects();
if ( ! self::fluent_is_ready() ) {
$state['projects_seeded'] = true;
$state['status'] = 'waiting';
$state['message'] = __( 'Aguardando Fluent Forms e FluentCRM ativos.', 'gustavoo-portfolio-core' );
update_option( self::STATE_OPTION, $state, false );
return;
}
if ( ! add_option( self::LOCK_OPTION, time(), '', false ) ) {
return;
}
try {
$contact_id = self::upsert_form( 'contact', __( 'Site — Contato e orçamento', 'gustavoo-portfolio-core' ), true );
$newsletter_id = self::upsert_form( 'newsletter', __( 'Site — Newsletter', 'gustavoo-portfolio-core' ), false );
self::upsert_crm_feed( $contact_id, 'contact' );
self::upsert_crm_feed( $newsletter_id, 'newsletter' );
self::save_form_ids( $contact_id, $newsletter_id );
update_option(
self::STATE_OPTION,
array(
'version' => self::VERSION,
'status' => 'complete',
'projects_seeded' => true,
'contact_form_id' => $contact_id,
'newsletter_form_id' => $newsletter_id,
'crm_feeds' => true,
'message' => __( 'Formulários e feeds do FluentCRM configurados.', 'gustavoo-portfolio-core' ),
),
false
);
} catch ( Throwable $error ) {
$state['status'] = 'error';
$state['message'] = sanitize_text_field( $error->getMessage() );
update_option( self::STATE_OPTION, $state, false );
} finally {
delete_option( self::LOCK_OPTION );
}
}
/**
* Configure the WordPress pieces that make the bundled theme work on a
* fresh installation.
*
* Existing sites are left untouched: each value is written only when the
* corresponding WordPress setting still has its fresh-install value.
*
* @return void
*/
private static function seed_site_configuration() {
$state = get_option( self::SITE_OPTION, array() );
if ( ! is_array( $state ) || empty( $state['pages'] ) ) {
$home_id = self::ensure_seed_page( 'inicio', __( 'Início', 'gustavoo-portfolio-core' ) );
$blog_id = self::ensure_seed_page( 'blog', __( 'Blog', 'gustavoo-portfolio-core' ) );
if ( $home_id && $blog_id && 'posts' === get_option( 'show_on_front' ) ) {
update_option( 'show_on_front', 'page' );
update_option( 'page_on_front', $home_id );
update_option( 'page_for_posts', $blog_id );
}
if ( $home_id && $blog_id ) {
$state = is_array( $state ) ? $state : array();
$state['pages'] = true;
update_option( self::SITE_OPTION, $state, false );
}
}
if ( empty( $state['widgets'] ) ) {
self::seed_theme_widgets();
$state = is_array( $state ) ? $state : array();
$state['widgets'] = true;
update_option( self::SITE_OPTION, $state, false );
}
}
/**
* Create a required empty page once and identify it by a private marker.
*
* @param string $slug Page slug.
* @param string $title Page title.
* @return int
*/
private static function ensure_seed_page( $slug, $title ) {
$existing = get_posts(
array(
'post_type' => 'page',
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => '_gso_seed_page',
'meta_value' => $slug,
)
);
if ( $existing ) {
return absint( $existing[0] );
}
$page = get_page_by_path( $slug, OBJECT, 'page' );
if ( $page ) {
update_post_meta( $page->ID, '_gso_seed_page', $slug );
return absint( $page->ID );
}
$page_id = wp_insert_post(
array(
'post_type' => 'page',
'post_status' => 'publish',
'post_title' => $title,
'post_name' => $slug,
),
true
);
if ( is_wp_error( $page_id ) ) {
return 0;
}
update_post_meta( $page_id, '_gso_seed_page', $slug );
return absint( $page_id );
}
/**
* Reproduce the sidebar and footer widget arrangement from this site.
* Widgets are added only to empty areas, so a user's arrangement wins.
*
* @return void
*/
private static function seed_theme_widgets() {
$sidebars = wp_get_sidebars_widgets();
$sidebars = is_array( $sidebars ) ? $sidebars : array();
$blog_widgets = isset( $sidebars['sidebar-blog'] ) ? (array) $sidebars['sidebar-blog'] : array();
$can_seed_blog = empty( $blog_widgets ) || array( 'gustavoo_portfolio_newsletter-1' ) === $blog_widgets;
if ( $can_seed_blog ) {
$newsletter = get_option( 'widget_gustavoo_portfolio_newsletter', array() );
$newsletter = is_array( $newsletter ) ? $newsletter : array();
$newsletter[1] = array(
'title' => __( 'Newsletter', 'gustavoo-portfolio-core' ),
'text' => __( 'Receba novos artigos sobre desenvolvimento, infraestrutura e automação.', 'gustavoo-portfolio-core' ),
'form_id' => 0,
);
$newsletter['_multiwidget'] = 1;
update_option( 'widget_gustavoo_portfolio_newsletter', $newsletter, false );
$blocks = get_option( 'widget_block', array() );
$blocks = is_array( $blocks ) ? $blocks : array();
$blocks[2] = array( 'content' => '<!-- wp:search /-->' );
$blocks[3] = array( 'content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:heading --><h2>Posts recentes</h2><!-- /wp:heading --><!-- wp:latest-posts /--></div><!-- /wp:group -->' );
$blocks[4] = array( 'content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:heading --><h2>Comentários</h2><!-- /wp:heading --><!-- wp:latest-comments {"displayAvatar":false,"displayDate":false,"displayExcerpt":false} /--></div><!-- /wp:group -->' );
$blocks[5] = array( 'content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:heading --><h2>Arquivos</h2><!-- /wp:heading --><!-- wp:archives /--></div><!-- /wp:group -->' );
$blocks[6] = array( 'content' => '<!-- wp:group --><div class="wp-block-group"><!-- wp:heading --><h2>Categorias</h2><!-- /wp:heading --><!-- wp:categories /--></div><!-- /wp:group -->' );
$blocks['_multiwidget'] = 1;
update_option( 'widget_block', $blocks, false );
$sidebars['sidebar-blog'] = array( 'gustavoo_portfolio_newsletter-1', 'block-2', 'block-3', 'block-4' );
}
if ( empty( $sidebars['footer-1'] ) ) {
$sidebars['footer-1'] = array( 'block-5', 'block-6' );
}
update_option( 'sidebars_widgets', $sidebars, false );
}
/**
* Repair the seeded contact form when an earlier migration did not persist.
*
* @return void
*/
private static function maybe_migrate_contact_form() {
if ( ! self::fluent_is_ready() ) {
return;
}
$form = self::find_seeded_form( 'contact' );
if ( ! $form ) {
return;
}
$fields = json_decode( (string) $form->form_fields, true );
$has_message = false;
foreach ( (array) ( $fields['fields'] ?? array() ) as $field ) {
if ( 'message' === (string) ( $field['attributes']['name'] ?? '' ) && 'textarea' === (string) ( $field['element'] ?? '' ) ) {
$has_message = true;
break;
}
}
if ( ! $has_message ) {
try {
self::upsert_form( 'contact', __( 'Site — Contato e orçamento', 'gustavoo-portfolio-core' ), true );
} catch ( Throwable $error ) {
// Keep the front end available if Fluent Forms is temporarily unavailable.
}
}
}
/**
* Check the optional integrations without triggering autoload errors.
*
* @return bool
*/
private static function fluent_is_ready() {
return defined( 'FLUENTFORM' )
&& defined( 'FLUENTCRM' )
&& function_exists( 'fluentformLoadFile' )
&& function_exists( 'FluentCrmApi' )
&& class_exists( '\\FluentForm\\App\\Models\\Form' )
&& class_exists( '\\FluentForm\\App\\Models\\FormMeta' )
&& class_exists( '\\FluentForm\\App\\Services\\Form\\FormService' )
&& class_exists( '\\FluentForm\\App\\Services\\Integrations\\FormIntegrationService' );
}
/**
* Create or reuse a seed-owned Fluent Form.
*
* @param string $key Deterministic form key.
* @param string $title Form title.
* @param bool $include_whatsapp Include the contact message field.
* @return int
* @throws Exception When Fluent Forms cannot create the form.
*/
private static function upsert_form( $key, $title, $include_whatsapp ) {
$form = self::find_seeded_form( $key );
$defaults = fluentformLoadFile( 'Services/FormBuilder/DefaultElements.php' );
$blank = \FluentForm\App\Models\Form::resolvePredefinedForm(
array(
'predefined' => 'blank_form',
'type' => 'form',
)
);
$structure = json_decode( (string) $blank['form_fields'], true );
if ( ! is_array( $defaults ) || ! is_array( $structure ) ) {
throw new Exception( 'Não foi possível carregar a estrutura padrão do Fluent Forms.' );
}
$email = $defaults['general']['input_email'];
$email['uniqElKey'] = 'el_gso_' . $key . '_email';
$email['attributes']['name'] = 'email';
$email['attributes']['placeholder'] = __( 'Seu melhor e-mail', 'gustavoo-portfolio-core' );
$email['settings']['label'] = __( 'E-mail', 'gustavoo-portfolio-core' );
$email['settings']['admin_field_label'] = __( 'E-mail', 'gustavoo-portfolio-core' );
$email['settings']['validation_rules']['required']['value'] = true;
$fields = array( $email );
if ( $include_whatsapp ) {
$whatsapp = $defaults['general']['input_mask'];
$whatsapp['uniqElKey'] = 'el_gso_' . $key . '_whatsapp';
$whatsapp['attributes']['name'] = 'whatsapp';
$whatsapp['attributes']['placeholder'] = '(31) 99999-9999';
$whatsapp['attributes']['data-mask'] = '(00) 00000-0000';
$whatsapp['settings']['label'] = __( 'WhatsApp', 'gustavoo-portfolio-core' );
$whatsapp['settings']['admin_field_label'] = __( 'WhatsApp', 'gustavoo-portfolio-core' );
$whatsapp['settings']['mobile_keyboard_type'] = 'tel';
$whatsapp['settings']['temp_mask'] = 'custom';
$whatsapp['settings']['data-mask-reverse'] = 'no';
$whatsapp['settings']['validation_rules']['required']['value'] = true;
$fields[] = $whatsapp;
$message = $defaults['general']['textarea'];
$message['uniqElKey'] = 'el_gso_' . $key . '_message';
$message['attributes']['name'] = 'message';
$message['attributes']['placeholder'] = __( 'Descreva o motivo do contato e o que você quer construir.', 'gustavoo-portfolio-core' );
$message['attributes']['rows'] = 5;
$message['settings']['label'] = __( 'Como posso ajudar?', 'gustavoo-portfolio-core' );
$message['settings']['admin_field_label'] = __( 'Motivo do contato', 'gustavoo-portfolio-core' );
$message['settings']['validation_rules']['required']['value'] = true;
$fields[] = $message;
}
$structure['fields'] = $fields;
$structure['submitButton']['settings']['button_ui']['text'] = $include_whatsapp
? __( 'Solicitar contato', 'gustavoo-portfolio-core' )
: __( 'Quero receber', 'gustavoo-portfolio-core' );
$service = new \FluentForm\App\Services\Form\FormService();
if ( $form ) {
$service->update(
array(
'form_id' => $form->id,
'title' => $title,
'status' => 'published',
'formFields' => wp_json_encode( $structure, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
)
);
self::localize_form_confirmation( $form->id, $include_whatsapp );
return absint( $form->id );
}
$form = $service->store(
array(
'predefined' => 'blank_form',
'type' => 'form',
)
);
$form = $service->update(
array(
'form_id' => $form->id,
'title' => $title,
'status' => 'published',
'formFields' => wp_json_encode( $structure, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ),
)
);
\FluentForm\App\Models\FormMeta::persist( $form->id, self::FORM_META, $key );
self::localize_form_confirmation( $form->id, $include_whatsapp );
return absint( $form->id );
}
/**
* Locate a form owned by this seed.
*
* @param string $key Seed key.
* @return object|null
*/
private static function find_seeded_form( $key ) {
$markers = \FluentForm\App\Models\FormMeta::where( 'meta_key', self::FORM_META )
->where( 'value', $key )
->get();
foreach ( $markers as $marker ) {
$form = \FluentForm\App\Models\Form::find( absint( $marker->form_id ) );
if ( $form ) {
return $form;
}
}
return null;
}
/**
* Use a concise Portuguese success message.
*
* @param int $form_id Form ID.
* @param bool $is_contact_form Whether this is the lead form.
* @return void
*/
private static function localize_form_confirmation( $form_id, $is_contact_form ) {
$settings = \FluentForm\App\Models\FormMeta::retrieve( 'formSettings', $form_id, array() );
$settings = is_array( $settings ) ? $settings : array();
$settings['confirmation'] = wp_parse_args(
array(
'redirectTo' => 'samePage',
'messageToShow' => $is_contact_form
? __( 'Recebi seus dados. Entrarei em contato em breve.', 'gustavoo-portfolio-core' )
: __( 'Inscrição recebida. Confira seu e-mail para confirmar.', 'gustavoo-portfolio-core' ),
'samePageFormBehavior' => 'hide_form',
),
isset( $settings['confirmation'] ) && is_array( $settings['confirmation'] ) ? $settings['confirmation'] : array()
);
\FluentForm\App\Models\FormMeta::persist( $form_id, 'formSettings', $settings );
}
/**
* Create the FluentCRM feed for one seed-owned form.
*
* @param int $form_id Form ID.
* @param string $context Contact or newsletter.
* @return void
*/
private static function upsert_crm_feed( $form_id, $context ) {
$existing_id = absint( \FluentForm\App\Models\FormMeta::retrieve( self::FEED_META, $form_id, 0 ) );
$defaults = apply_filters( 'fluentform/get_integration_defaults_fluentcrm', array(), $form_id );
$defaults = is_array( $defaults ) ? $defaults : array();
$is_contact = 'contact' === $context;
$feed = array_replace(
$defaults,
array(
'name' => $is_contact ? 'Site / Contato' : 'Site / Newsletter',
'email' => 'email',
'enabled' => true,
'double_opt_in' => ! $is_contact,
'other_fields' => $is_contact
? array(
array(
'label' => 'phone',
'item_value' => '{inputs.whatsapp}',
),
)
: array(),
'conditionals' => array(
'status' => false,
'type' => 'all',
'conditions' => array(),
),
)
);
$result = ( new \FluentForm\App\Services\Integrations\FormIntegrationService() )->update(
array(
'form_id' => $form_id,
'integration_id' => $existing_id,
'integration_name' => 'fluentcrm',
'data_type' => 'array',
'status' => true,
'integration' => $feed,
)
);
if ( ! empty( $result['integration_id'] ) ) {
\FluentForm\App\Models\FormMeta::persist( $form_id, self::FEED_META, absint( $result['integration_id'] ) );
}
}
/**
* Share form IDs with the theme settings option.
*
* @param int $contact_id Contact form ID.
* @param int $newsletter_id Newsletter form ID.
* @return void
*/
private static function save_form_ids( $contact_id, $newsletter_id ) {
$settings = GSO_Portfolio_Settings::get_settings();
$settings['contact_form_id'] = absint( $contact_id );
$settings['newsletter_form_id'] = absint( $newsletter_id );
update_option( GSO_Portfolio_Settings::OPTION_NAME, $settings, false );
}
/**
* Add starter projects only when their deterministic markers are absent.
*
* @return void
*/
private static function seed_projects() {
$projects = array(
'aurelia-online' => array(
'title' => 'Aurelia Online',
'excerpt' => __( 'Plataforma online desenvolvida com foco em performance, experiência do usuário e evolução contínua.', 'gustavoo-portfolio-core' ),
'type' => 'Plataforma web',
'technologies' => array( 'Web', 'APIs', 'Infraestrutura' ),
'featured' => true,
),
'lumenix-engine' => array(
'title' => 'Lumenix Engine',
'excerpt' => __( 'Engine e ecossistema técnico para experiências interativas e produtos digitais.', 'gustavoo-portfolio-core' ),
'type' => 'Games',
'technologies' => array( 'C++', 'Games', 'Performance' ),
'featured' => true,
),
'aurabet' => array(
'title' => 'AuraBet',
'excerpt' => __( 'Produto digital com arquitetura, integrações e infraestrutura preparadas para escala.', 'gustavoo-portfolio-core' ),
'type' => 'Produto digital',
'technologies' => array( 'Full Stack', 'Docker', 'APIs' ),
'featured' => true,
),
'visionforge' => array(
'title' => 'VisionForge',
'excerpt' => __( 'Solução criada para transformar processos complexos em uma experiência clara e eficiente.', 'gustavoo-portfolio-core' ),
'type' => 'Software',
'technologies' => array( 'Automação', 'Dados', 'Web' ),
'featured' => true,
),
'papel-e-cor' => array(
'title' => 'Papel & Cor',
'excerpt' => __( 'Experiência de e-commerce otimizada para catálogo, compra e conversão.', 'gustavoo-portfolio-core' ),
'type' => 'E-commerce',
'technologies' => array( 'WordPress', 'WooCommerce', 'UX' ),
'featured' => false,
),
'lumina-hub' => array(
'title' => 'Lumina Hub',
'excerpt' => __( 'Hub digital que reúne conteúdo, serviços e integrações em uma única plataforma.', 'gustavoo-portfolio-core' ),
'type' => 'Plataforma web',
'technologies' => array( 'WordPress', 'APIs', 'Automação' ),
'featured' => false,
),
);
$order = 0;
foreach ( $projects as $key => $project ) {
$existing = get_posts(
array(
'post_type' => GSO_Projects::POST_TYPE,
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'meta_key' => self::PROJECT_META,
'meta_value' => $key,
)
);
if ( $existing ) {
++$order;
continue;
}
$post_id = wp_insert_post(
array(
'post_type' => GSO_Projects::POST_TYPE,
'post_status' => 'publish',
'post_title' => $project['title'],
'post_excerpt' => $project['excerpt'],
'menu_order' => $order,
),
true
);
if ( is_wp_error( $post_id ) ) {
++$order;
continue;
}
update_post_meta( $post_id, self::PROJECT_META, $key );
update_post_meta( $post_id, GSO_Projects::META_URL, 'https://gustavoo.me/#projetos' );
update_post_meta( $post_id, GSO_Projects::META_CLIENT, __( 'Projeto selecionado', 'gustavoo-portfolio-core' ) );
update_post_meta( $post_id, GSO_Projects::META_YEAR, (int) gmdate( 'Y' ) );
update_post_meta( $post_id, GSO_Projects::META_LINK_LABEL, __( 'Conhecer projeto', 'gustavoo-portfolio-core' ) );
update_post_meta( $post_id, GSO_Projects::META_FEATURED, $project['featured'] ? '1' : '' );
wp_set_object_terms( $post_id, $project['type'], GSO_Projects::TAX_TYPE );
wp_set_object_terms( $post_id, $project['technologies'], GSO_Projects::TAX_TECH );
++$order;
}
}
}