From 200d34fadbaafeb1757f334ace96e462225d3c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Fri, 8 May 2026 23:00:23 -0300 Subject: [PATCH 01/65] chore: add .dockerignore --- .dockerignore | 78 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..945b48ff --- /dev/null +++ b/.dockerignore @@ -0,0 +1,78 @@ +# Version control & editors +.git +.gitattributes +.gitignore +.github +.editorconfig +.idea +.vscode +.fleet +.nova +.zed +.phpactor.json + +# Dependencies (installed inside the image) +vendor +node_modules +maizzle/node_modules + +# Build artifacts (generated inside the image) +public/build +public/hot +public/storage +bootstrap/ssr +bootstrap/cache/*.php + +# Generated route/action helpers (regenerated by Wayfinder at build time) +resources/js/actions +resources/js/routes +resources/js/wayfinder + +# Runtime storage (mounted at runtime; shouldn't bloat build context) +storage/logs +storage/framework/cache/data +storage/framework/sessions +storage/framework/views +storage/pail +storage/*.key +storage/oauth-*.key + +# Test caches +.phpunit.cache +.phpunit.result.cache + +# Environment files (image must not embed secrets) +.env +.env.backup +.env.local +.env.production +.env.testing + +# Composer auth +auth.json + +# Personal homelab/deploy artifacts (not part of upstream) +deploy +HOMELAB-DEPLOY-GUIDE.md + +# Compose overrides (per-developer) +compose.override.yaml +compose.override.yml +docker-compose.override.yaml +docker-compose.override.yml + +# Docs site (if present) +docs + +# OS / misc +.DS_Store +npm-debug.log +yarn-error.log +*.log + +# Plan / agent context (not relevant for build) +.claude +.mcp.json +CLAUDE.md +GEMINI.md +boost.json From 4ce5c9173f5188f7a753cb0b3fbf63016ac4d68f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Fri, 8 May 2026 23:00:23 -0300 Subject: [PATCH 02/65] feat(docker): add multi-stage Dockerfile with dev and production targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a self-contained Docker build under docker/ that boots the full Laravel + Vite + Reverb + Horizon + scheduler stack inside a single container. The Dockerfile exposes two targets sharing a common system-base layer (PHP 8.4-FPM Alpine + Postgres/Redis/intl/sockets/redis extensions): - dev: bind-mount source at runtime, runs Vite via supervisord, hot reloads PHP via opcache.validate_timestamps=1, UID/GID build args align container writes with the host user. - production: ships the prebuilt application — composer --no-dev, npm run build + build:ssr, wayfinder TS pre-generated, OpCache hardened, fixed UID 1000. Sidecar configs (nginx, php.{dev,prod}.ini, supervisord.{dev,prod}.conf, entrypoint, postgres-init for the test DB) live next to the Dockerfile so the build context is self-describing. The entrypoint is idempotent and handles APP_KEY generation, migrations, storage:link, Passport keys, Wayfinder regen, and dependency reinstall on every boot. --- docker/.env.docker.example | 177 ++++++++++++++++++++++++++++++++++ docker/Dockerfile | 179 +++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 110 +++++++++++++++++++++ docker/nginx.conf | 31 ++++++ docker/php.dev.ini | 22 +++++ docker/php.prod.ini | 20 ++++ docker/postgres-init.sh | 11 +++ docker/supervisord.dev.conf | 69 ++++++++++++++ docker/supervisord.prod.conf | 58 ++++++++++++ 9 files changed, 677 insertions(+) create mode 100644 docker/.env.docker.example create mode 100644 docker/Dockerfile create mode 100755 docker/entrypoint.sh create mode 100644 docker/nginx.conf create mode 100644 docker/php.dev.ini create mode 100644 docker/php.prod.ini create mode 100755 docker/postgres-init.sh create mode 100644 docker/supervisord.dev.conf create mode 100644 docker/supervisord.prod.conf diff --git a/docker/.env.docker.example b/docker/.env.docker.example new file mode 100644 index 00000000..0fe8e7d6 --- /dev/null +++ b/docker/.env.docker.example @@ -0,0 +1,177 @@ +APP_NAME="TryPost" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +# Self-hosted mode (skips payment requirements) +SELF_HOSTED=true + +TELESCOPE_ENABLED=false + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +# Database — service name "pgsql" inside the Compose network. +DB_CONNECTION=pgsql +DB_HOST=pgsql +DB_PORT=5432 +DB_DATABASE=trypost +DB_USERNAME=postgres +DB_PASSWORD=password + +# Session +SESSION_DRIVER=database +SESSION_LIFETIME=1440 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +# Broadcasting, Queue, Cache — all Redis-backed in Docker. +BROADCAST_CONNECTION=reverb +QUEUE_CONNECTION=redis +CACHE_STORE=redis + +# File Storage — local-disk by default; switch to s3/r2 for production. +FILESYSTEM_DISK=local + +# Redis — service name "redis" inside the Compose network. +REDIS_HOST=redis +REDIS_PASSWORD=null +REDIS_PORT=6379 + +# Mail — service name "mailpit" inside the Compose network. UI at :8025. +MAIL_MAILER=smtp +MAIL_HOST=mailpit +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +# Reverb (WebSockets) — host=localhost is browser-perspective via host port-forward. +REVERB_APP_ID=1001 +REVERB_APP_KEY=trypost-reverb-key +REVERB_APP_SECRET=trypost-reverb-secret +REVERB_HOST=localhost +REVERB_PORT=8080 +REVERB_SCHEME=http + +# AWS S3 (set FILESYSTEM_DISK=s3) +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_URL= + +# Cloudflare R2 (set FILESYSTEM_DISK=r2) +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_ENDPOINT= +R2_REGION=auto +R2_BUCKET= +R2_URL= + +# ============================================ +# Social Platform Credentials (optional in dev) +# ============================================ +LINKEDIN_CLIENT_ID= +LINKEDIN_CLIENT_SECRET= +LINKEDIN_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin/callback" +LINKEDIN_PAGE_CLIENT_REDIRECT="${APP_URL}/accounts/linkedin-page/callback" + +X_CLIENT_ID= +X_CLIENT_SECRET= +X_CLIENT_REDIRECT="${APP_URL}/accounts/x/callback" + +TIKTOK_CLIENT_ID= +TIKTOK_CLIENT_SECRET= +TIKTOK_CLIENT_REDIRECT="${APP_URL}/accounts/tiktok/callback" + +FACEBOOK_CLIENT_ID= +FACEBOOK_CLIENT_SECRET= +FACEBOOK_CLIENT_REDIRECT="${APP_URL}/accounts/facebook/callback" + +INSTAGRAM_CLIENT_ID= +INSTAGRAM_CLIENT_SECRET= +INSTAGRAM_CLIENT_REDIRECT="${APP_URL}/accounts/instagram/callback" + +THREADS_CLIENT_ID= +THREADS_CLIENT_SECRET= +THREADS_CLIENT_REDIRECT="${APP_URL}/accounts/threads/callback" + +GOOGLE_AUTH_ENABLED=false +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_CLIENT_REDIRECT="${APP_URL}/accounts/youtube/callback" +GOOGLE_AUTH_CALLBACK="${APP_URL}/auth/google/callback" + +GITHUB_AUTH_ENABLED=false +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_AUTH_CALLBACK="${APP_URL}/auth/github/callback" + +PINTEREST_CLIENT_ID= +PINTEREST_CLIENT_SECRET= +PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback" + +# AI Services +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= +ELEVENLABS_API_KEY= + +AI_TEXT_PROVIDER=openai +AI_TEXT_MODEL=gpt-5.4 +AI_IMAGE_PROVIDER=openai +AI_AUDIO_PROVIDER=elevenlabs + +# Stripe (only required when SELF_HOSTED=false) +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= + +STRIPE_STARTER_MONTHLY= +STRIPE_STARTER_YEARLY= +STRIPE_PLUS_MONTHLY= +STRIPE_PLUS_YEARLY= +STRIPE_PRO_MONTHLY= +STRIPE_PRO_YEARLY= +STRIPE_MAX_MONTHLY= +STRIPE_MAX_YEARLY= + +# Telemetry — disabled by default in Docker dev. +NIGHTWATCH_ENABLED=false +NIGHTWATCH_TOKEN= + +# Media Services +UNSPLASH_ACCESS_KEY= +UNSPLASH_SECRET_KEY= +GIPHY_API_KEY= + +# Analytics (optional) +GTM_ID= +POSTHOG_ENABLED=false +POSTHOG_API_KEY= +POSTHOG_HOST=https://us.i.posthog.com + +# Vite +VITE_APP_NAME="${APP_NAME}" +VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" +VITE_REVERB_HOST="${REVERB_HOST}" +VITE_REVERB_PORT="${REVERB_PORT}" +VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}" +VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}" +VITE_POSTHOG_HOST="${POSTHOG_HOST}" diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..5025c384 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,179 @@ +# syntax=docker/dockerfile:1.7 + +# ---------------------------------------------------------------------------- +# TryPost Dockerfile — multi-stage with `dev` and `production` targets. +# +# docker build --target dev -t trypost:dev -f docker/Dockerfile . +# docker build --target production -t trypost:prod -f docker/Dockerfile . +# ---------------------------------------------------------------------------- + +ARG PHP_VERSION=8.4 +ARG NODE_VERSION=22 + +# ---------------------------------------------------------------------------- +# Stage 1: system-base — PHP-FPM + system packages + extensions +# ---------------------------------------------------------------------------- +FROM php:${PHP_VERSION}-fpm-alpine AS system-base + +RUN apk add --no-cache \ + nginx \ + supervisor \ + bash \ + curl \ + git \ + unzip \ + shadow \ + tzdata \ + postgresql-client \ + postgresql-dev \ + libpng-dev \ + libjpeg-turbo-dev \ + freetype-dev \ + libzip-dev \ + oniguruma-dev \ + icu-dev \ + linux-headers \ + $PHPIZE_DEPS \ + && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-install -j"$(nproc)" \ + pdo_pgsql \ + pgsql \ + gd \ + zip \ + opcache \ + bcmath \ + exif \ + pcntl \ + intl \ + sockets \ + && pecl install redis \ + && docker-php-ext-enable redis \ + && apk del $PHPIZE_DEPS \ + && rm -rf /tmp/* /var/cache/apk/* + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +WORKDIR /var/www/html + +# ---------------------------------------------------------------------------- +# Stage 2: composer-deps — full PHP deps (incl. dev) for tooling/asset build +# ---------------------------------------------------------------------------- +FROM system-base AS composer-deps + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + +# ---------------------------------------------------------------------------- +# Stage 3: composer-deps-prod — production deps only (no dev tooling) +# ---------------------------------------------------------------------------- +FROM system-base AS composer-deps-prod + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-scripts \ + --no-autoloader \ + --prefer-dist \ + --no-interaction + +# ---------------------------------------------------------------------------- +# Stage 4: wayfinder-gen — generate TS route helpers (needs PHP + Laravel) +# ---------------------------------------------------------------------------- +FROM composer-deps AS wayfinder-gen + +COPY --from=composer-deps /var/www/html/vendor ./vendor +COPY . . + +# Stub env so artisan can boot; overridden at runtime. +ENV APP_KEY=base64:c3R1Yi13YXlmaW5kZXItZ2VuLWtleS1mb3ItYnVpbGRpbmctYXNzZXRzMA== \ + APP_ENV=production \ + APP_DEBUG=false + +RUN composer dump-autoload --no-scripts --optimize \ + && php artisan wayfinder:generate --with-form + +# ---------------------------------------------------------------------------- +# Stage 5: asset-build — Vite + Inertia SSR build +# ---------------------------------------------------------------------------- +FROM node:${NODE_VERSION}-alpine AS asset-build + +WORKDIR /var/www/html + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +# Pull source + freshly generated wayfinder TS from previous stage. +COPY --from=wayfinder-gen /var/www/html /var/www/html + +RUN npm run build && npm run build:ssr + +# ---------------------------------------------------------------------------- +# Stage 6: dev — local development image (bind-mount the source at runtime) +# ---------------------------------------------------------------------------- +FROM system-base AS dev + +ARG UID=1000 +ARG GID=1000 + +# Node + npm for in-container Vite, npm scripts, ad-hoc tooling. +RUN apk add --no-cache nodejs npm + +# Create non-root app user matching host UID/GID for clean bind-mount writes. +RUN groupmod -g "${GID}" www-data 2>/dev/null || groupadd -g "${GID}" app \ + && (id -u app >/dev/null 2>&1 || useradd -u "${UID}" -g "${GID}" -d /home/app -m -s /bin/bash app) \ + && chown -R "${UID}:${GID}" /var/www/html + +COPY docker/nginx.conf /etc/nginx/http.d/default.conf +COPY docker/php.dev.ini /usr/local/etc/php/conf.d/99-trypost.ini +COPY docker/supervisord.dev.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +COPY docker/.env.docker.example /var/www/html/.env.docker.example +RUN chmod +x /usr/local/bin/entrypoint.sh + +ENV TRYPOST_TARGET=dev + +EXPOSE 80 5173 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \ + CMD curl -fsS http://127.0.0.1/up || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] + +# ---------------------------------------------------------------------------- +# Stage 7: production — self-contained image for self-hosters +# ---------------------------------------------------------------------------- +FROM system-base AS production + +# Non-root runtime user (fixed UID; overridable per orchestrator if needed). +RUN addgroup -g 1000 -S app \ + && adduser -u 1000 -S app -G app -h /home/app + +COPY docker/nginx.conf /etc/nginx/http.d/default.conf +COPY docker/php.prod.ini /usr/local/etc/php/conf.d/99-trypost.ini +COPY docker/supervisord.prod.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Production composer deps only. +COPY --from=composer-deps-prod /var/www/html/vendor /var/www/html/vendor + +# Application source + generated wayfinder TS + built assets. +COPY --from=wayfinder-gen /var/www/html /var/www/html +COPY --from=asset-build /var/www/html/public/build /var/www/html/public/build +COPY --from=asset-build /var/www/html/bootstrap/ssr /var/www/html/bootstrap/ssr + +RUN composer dump-autoload --optimize --classmap-authoritative --no-scripts \ + && chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache + +ENV TRYPOST_TARGET=production + +EXPOSE 80 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsS http://127.0.0.1/up || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 00000000..56d24bfd --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# TryPost — container entrypoint. Idempotent first-run setup, then exec supervisord. + +set -e + +cd /var/www/html + +TARGET="${TRYPOST_TARGET:-dev}" + +# 1) Bootstrap .env from the Docker template on first dev boot. The bind-mount +# in dev hides /var/www/html/.env.docker.example, so prefer docker/ first. +if [ "${TRYPOST_DOCKER_BOOTSTRAP:-0}" = "1" ] && [ ! -f .env ]; then + if [ -f docker/.env.docker.example ]; then + echo "[entrypoint] seeding .env from docker/.env.docker.example" + cp docker/.env.docker.example .env + elif [ -f .env.docker.example ]; then + echo "[entrypoint] seeding .env from .env.docker.example" + cp .env.docker.example .env + fi + # Hand the seeded .env over to the host user so they can edit it. + chown "${UID:-1000}:${GID:-1000}" .env 2>/dev/null || true +fi + +# 2) Skip-bootstrap escape hatch for advanced users. +if [ "${TRYPOST_SKIP_BOOTSTRAP:-0}" = "1" ]; then + echo "[entrypoint] TRYPOST_SKIP_BOOTSTRAP=1 — exec'ing supervisord without setup" + exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf +fi + +# 3) Re-install composer deps if vendor was wiped (down -v in dev). +if [ "${TARGET}" = "dev" ] && [ ! -f vendor/autoload.php ]; then + echo "[entrypoint] vendor/ missing — running composer install" + composer install --no-interaction --prefer-dist +fi + +# 4) Re-install node_modules if empty/wiped (dev only; vite needs them). +# Anonymous volumes pre-create the dir, so check for npm's lockfile instead. +if [ "${TARGET}" = "dev" ] && [ ! -f node_modules/.package-lock.json ]; then + echo "[entrypoint] node_modules/ empty — running npm ci" + npm ci --no-audit --no-fund +fi + +# 5) APP_KEY — generate on first boot if blank. +if [ -f .env ] && ! grep -qE '^APP_KEY=base64:' .env; then + echo "[entrypoint] generating APP_KEY" + php artisan key:generate --force +fi + +# 6) Wait for Postgres to be reachable. +DB_HOST_VALUE="${DB_HOST:-pgsql}" +DB_PORT_VALUE="${DB_PORT:-5432}" +DB_USER_VALUE="${DB_USERNAME:-postgres}" +DB_NAME_VALUE="${DB_DATABASE:-trypost}" + +echo "[entrypoint] waiting for postgres at ${DB_HOST_VALUE}:${DB_PORT_VALUE}" +WAIT_ATTEMPTS=0 +until pg_isready -h "${DB_HOST_VALUE}" -p "${DB_PORT_VALUE}" -U "${DB_USER_VALUE}" -d "${DB_NAME_VALUE}" >/dev/null 2>&1; do + WAIT_ATTEMPTS=$((WAIT_ATTEMPTS + 1)) + if [ "${WAIT_ATTEMPTS}" -gt 60 ]; then + echo "[entrypoint] postgres not reachable after 60s — continuing anyway" + break + fi + sleep 1 +done + +# 7) Run migrations (graceful: succeeds even when nothing to migrate). +echo "[entrypoint] running migrations" +php artisan migrate --force --graceful || true + +# 8) storage:link if missing. +if [ ! -L public/storage ]; then + echo "[entrypoint] linking storage" + php artisan storage:link --force || true +fi + +# 9) Passport keys on first boot. +if [ ! -f storage/oauth-private.key ]; then + echo "[entrypoint] generating Passport keys" + php artisan passport:keys --force || true +fi + +# 10) Wayfinder TS regen — Vite needs the files before it boots. +echo "[entrypoint] regenerating wayfinder helpers" +php artisan wayfinder:generate --with-form || true + +# 11) Cache strategy: prod = pre-cache; dev = clear. +if [ "${TARGET}" = "production" ]; then + php artisan config:cache + php artisan route:cache + php artisan view:cache + php artisan event:cache +else + php artisan config:clear + php artisan route:clear + php artisan view:clear + php artisan event:clear +fi + +# 12) Permissions. +if [ "${TARGET}" = "production" ]; then + chown -R app:app storage bootstrap/cache +else + # Dev: ensure UID-mapped user owns runtime dirs. + APP_UID="${UID:-1000}" + APP_GID="${GID:-1000}" + chown -R "${APP_UID}:${APP_GID}" storage bootstrap/cache 2>/dev/null || true +fi + +echo "[entrypoint] ready — handing off to supervisord" +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 00000000..0682a919 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 80; + listen [::]:80; + server_name _; + root /var/www/html/public; + index index.php; + + charset utf-8; + client_max_body_size 100M; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location = /favicon.ico { access_log off; log_not_found off; } + location = /robots.txt { access_log off; log_not_found off; } + + error_page 404 /index.php; + + location ~ \.php$ { + fastcgi_pass 127.0.0.1:9000; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + fastcgi_read_timeout 120s; + include fastcgi_params; + fastcgi_hide_header X-Powered-By; + } + + location ~ /\.(?!well-known).* { + deny all; + } +} diff --git a/docker/php.dev.ini b/docker/php.dev.ini new file mode 100644 index 00000000..58eb5391 --- /dev/null +++ b/docker/php.dev.ini @@ -0,0 +1,22 @@ +; TryPost — PHP settings for local development. +memory_limit = 512M +upload_max_filesize = 100M +post_max_size = 100M +max_execution_time = 120 +max_input_vars = 5000 + +; Hot-reload PHP edits without restarting FPM. +opcache.enable = 1 +opcache.memory_consumption = 128 +opcache.max_accelerated_files = 10000 +opcache.validate_timestamps = 1 +opcache.revalidate_freq = 0 + +; Surface errors loudly in dev. +display_errors = On +display_startup_errors = On +error_reporting = E_ALL +log_errors = On +error_log = /proc/self/fd/2 + +; Xdebug optional — enable via XDEBUG_MODE in compose.override.yaml. diff --git a/docker/php.prod.ini b/docker/php.prod.ini new file mode 100644 index 00000000..9152b763 --- /dev/null +++ b/docker/php.prod.ini @@ -0,0 +1,20 @@ +; TryPost — PHP settings for production. +memory_limit = 256M +upload_max_filesize = 100M +post_max_size = 100M +max_execution_time = 60 +max_input_vars = 5000 + +; OpCache hardened for production: no timestamp checks, larger interned-string buffer. +opcache.enable = 1 +opcache.memory_consumption = 192 +opcache.max_accelerated_files = 20000 +opcache.validate_timestamps = 0 +opcache.interned_strings_buffer = 16 + +; Security +expose_php = Off +display_errors = Off +display_startup_errors = Off +log_errors = On +error_log = /proc/self/fd/2 diff --git a/docker/postgres-init.sh b/docker/postgres-init.sh new file mode 100755 index 00000000..5d3d2ad8 --- /dev/null +++ b/docker/postgres-init.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Creates the test database expected by phpunit.xml on first Postgres boot. +# Postgres-alpine runs every *.sh / *.sql in /docker-entrypoint-initdb.d/ once, +# right after the primary database (POSTGRES_DB) is initialized. + +set -e + +psql -v ON_ERROR_STOP=1 --username "${POSTGRES_USER}" --dbname "${POSTGRES_DB}" <<-EOSQL + CREATE DATABASE trypost_test; + GRANT ALL PRIVILEGES ON DATABASE trypost_test TO "${POSTGRES_USER}"; +EOSQL diff --git a/docker/supervisord.dev.conf b/docker/supervisord.dev.conf new file mode 100644 index 00000000..983aaa09 --- /dev/null +++ b/docker/supervisord.dev.conf @@ -0,0 +1,69 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid + +[program:php-fpm] +command=php-fpm -F +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g 'daemon off;' +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:reverb] +command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080 +autostart=true +autorestart=true +priority=30 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:horizon] +command=php /var/www/html/artisan horizon +autostart=true +autorestart=true +priority=30 +stopwaitsecs=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:scheduler] +command=php /var/www/html/artisan schedule:work +autostart=true +autorestart=true +priority=40 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:vite] +command=/bin/sh -c "exec /usr/bin/npm run dev -- --host 0.0.0.0" +directory=/var/www/html +autostart=true +autorestart=true +startsecs=10 +priority=50 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker/supervisord.prod.conf b/docker/supervisord.prod.conf new file mode 100644 index 00000000..95e29ddb --- /dev/null +++ b/docker/supervisord.prod.conf @@ -0,0 +1,58 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/run/supervisord.pid + +[program:php-fpm] +command=php-fpm -F +autostart=true +autorestart=true +priority=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=nginx -g 'daemon off;' +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:reverb] +command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080 +autostart=true +autorestart=unexpected +exitcodes=0,2 +priority=30 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:horizon] +command=php /var/www/html/artisan horizon +autostart=true +autorestart=true +priority=30 +stopwaitsecs=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:scheduler] +command=php /var/www/html/artisan schedule:work +autostart=true +autorestart=true +priority=40 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 From d15981b8fdeeba3448fa5c84c8bb743622d0bc53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Fri, 8 May 2026 23:00:24 -0300 Subject: [PATCH 03/65] feat(docker): replace stale Sail compose with self-contained stack The previous compose.yaml referenced ./vendor/laravel/sail/runtimes/8.5 which was never wired up (sail isn't installed and the runtime path doesn't exist), so docker compose up failed out of the box. It also lacked Reverb, queue workers, and the scheduler. The new compose.yaml: - Builds the dev target of docker/Dockerfile so a single `docker compose up --build` brings up the entire stack with no host PHP or Node prerequisites. - Bind-mounts the project root for hot-reload, with named volumes for vendor/ and node_modules/ so image-baked deps survive the bind. - Pins Postgres to 16-alpine (matches CI) and Redis to 7-alpine. - Healthchecks gate `app` startup until pgsql and redis are ready. - Sets DB_HOST=pgsql, DB_USERNAME=postgres, REDIS_HOST=redis as process env so .env.testing's pre-existing CI defaults (DB_HOST=127.0.0.1, DB_USERNAME=root) don't break tests run from inside the container. compose.override.yaml.example documents per-developer customizations (UID/GID alignment on Linux, port conflicts, Xdebug, Selenium) without forcing a specific workflow. .env.testing.local is added to .gitignore as a safety net for any future per-developer testing override. --- .gitignore | 1 + compose.override.yaml.example | 52 +++++++++++++ compose.yaml | 138 ++++++++++++++++------------------ 3 files changed, 118 insertions(+), 73 deletions(-) create mode 100644 compose.override.yaml.example diff --git a/.gitignore b/.gitignore index b23d783d..7c9dcfd5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ .env .env.backup .env.production +.env.testing.local .phpactor.json .phpunit.result.cache Homestead.json diff --git a/compose.override.yaml.example b/compose.override.yaml.example new file mode 100644 index 00000000..5b21cdb2 --- /dev/null +++ b/compose.override.yaml.example @@ -0,0 +1,52 @@ +# TryPost — Compose override example. +# +# Compose automatically merges `compose.override.yaml` (gitignored, per-developer) +# on top of `compose.yaml`. Copy this file to start customizing: +# +# cp compose.override.yaml.example compose.override.yaml +# +# Common overrides are commented below. Uncomment what you need. + +services: + app: + # Match container UID/GID to your host user (Linux contributors on + # systems where your UID isn't 1000). + # build: + # args: + # UID: 1001 + # GID: 1001 + + # Enable Xdebug (requires installing the extension via a Dockerfile + # tweak; left commented as a hint). + # environment: + # XDEBUG_MODE: debug + # XDEBUG_CONFIG: 'client_host=host.docker.internal' + + # Forward additional ports if 8000/5173/8080 conflict with another + # service on your host. + # ports: + # - '9000:80' + + pgsql: + # Free up host port 5432 (e.g. when running another Postgres locally). + # ports: + # - '54320:5432' + + redis: + # ports: + # - '63790:6379' + + mailpit: + # Move the Mailpit UI off port 8025. + # ports: + # - '8025:8025' + # - '1025:1025' + +# Add additional services here — e.g. a Selenium container for Dusk tests: +# +# selenium: +# image: selenium/standalone-chromium +# extra_hosts: +# - 'host.docker.internal:host-gateway' +# volumes: +# - '/dev/shm:/dev/shm' diff --git a/compose.yaml b/compose.yaml index f8b765f0..244baa04 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,91 +1,83 @@ services: - laravel.test: + app: build: - context: './vendor/laravel/sail/runtimes/8.5' - dockerfile: Dockerfile + context: . + dockerfile: docker/Dockerfile + target: dev args: - WWWGROUP: '${WWWGROUP}' - image: 'sail-8.5/app' + UID: ${UID:-1000} + GID: ${GID:-1000} + image: trypost-app:dev + ports: + - '${APP_PORT:-8000}:80' + - '${REVERB_PORT:-8080}:8080' + - '${VITE_PORT:-5173}:5173' + environment: + TRYPOST_DOCKER_BOOTSTRAP: '1' + UID: ${UID:-1000} + GID: ${GID:-1000} + # Real env vars take precedence over .env.testing (which pins + # DB_HOST=127.0.0.1, DB_USERNAME=root). Without these, the test + # suite can't see Postgres / Redis from inside the container. + DB_HOST: pgsql + DB_USERNAME: postgres + REDIS_HOST: redis + volumes: + - .:/var/www/html + - app-vendor:/var/www/html/vendor + - app-node-modules:/var/www/html/node_modules extra_hosts: - 'host.docker.internal:host-gateway' - ports: - - '${APP_PORT:-80}:80' - - '${VITE_PORT:-5173}:${VITE_PORT:-5173}' - environment: - WWWUSER: '${WWWUSER}' - LARAVEL_SAIL: 1 - XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' - XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' - IGNITION_LOCAL_SITES_PATH: '${PWD}' - volumes: - - '.:/var/www/html' - networks: - - sail depends_on: - - pgsql - - redis - - mailpit - - selenium + pgsql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1/up'] + interval: 30s + timeout: 5s + retries: 5 + start_period: 90s + pgsql: - image: 'postgres:18-alpine' + image: postgres:16-alpine + environment: + POSTGRES_DB: ${DB_DATABASE:-trypost} + POSTGRES_USER: ${DB_USERNAME:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-password} + volumes: + - pgdata:/var/lib/postgresql/data + - ./docker/postgres-init.sh:/docker-entrypoint-initdb.d/10-create-test-db.sh:ro ports: - '${FORWARD_DB_PORT:-5432}:5432' - environment: - PGPASSWORD: '${DB_PASSWORD:-secret}' - POSTGRES_DB: '${DB_DATABASE}' - POSTGRES_USER: '${DB_USERNAME}' - POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' - volumes: - - 'sail-pgsql:/var/lib/postgresql' - - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' - networks: - - sail healthcheck: - test: - - CMD - - pg_isready - - '-q' - - '-d' - - '${DB_DATABASE}' - - '-U' - - '${DB_USERNAME}' - retries: 3 + test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-trypost}'] + interval: 10s timeout: 5s + retries: 5 + redis: - image: 'redis:alpine' + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redisdata:/data ports: - '${FORWARD_REDIS_PORT:-6379}:6379' - volumes: - - 'sail-redis:/data' - networks: - - sail healthcheck: - test: - - CMD - - redis-cli - - ping - retries: 3 - timeout: 5s + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 3s + retries: 5 + mailpit: - image: 'axllent/mailpit:latest' + image: axllent/mailpit:latest ports: - - '${FORWARD_MAILPIT_PORT:-1025}:1025' - - '${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025' - networks: - - sail - selenium: - image: selenium/standalone-chromium - extra_hosts: - - 'host.docker.internal:host-gateway' - volumes: - - '/dev/shm:/dev/shm' - networks: - - sail -networks: - sail: - driver: bridge + - '${FORWARD_MAILPIT_SMTP_PORT:-1025}:1025' + - '${FORWARD_MAILPIT_UI_PORT:-8025}:8025' + volumes: - sail-pgsql: - driver: local - sail-redis: - driver: local + pgdata: + redisdata: + app-vendor: + app-node-modules: From 82c06aa215253ab01d0b55bee217202cf9e76ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Fri, 8 May 2026 23:00:24 -0300 Subject: [PATCH 04/65] docs: document Docker development workflow in README Adds a "Run with Docker" section between "Get Started" and "Contributing" covering: single-command boot, common commands, compose.override.yaml.example, troubleshooting (port conflicts, UID/GID on Linux, first-boot expectations), and the production image build hint for self-hosters. --- README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/README.md b/README.md index 3e96fdeb..e9e03bb1 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,69 @@ ## Get Started | **Self-host** | Free forever. [Installation guide →](https://trypost.it/docs/getting-started/installation) | | **Run with AI assistants** | [Connect Claude / Cursor / ChatGPT via MCP →](https://trypost.it/docs/mcp) | +## Run with Docker (recommended for contributors) + +A single command brings up the full stack — Laravel app, Postgres, Redis, Mailpit, Reverb (websockets), Horizon (queue dashboard), and the scheduler — with no host PHP or Node prerequisites: + +```bash +git clone https://github.com/trypost-it/trypost.git +cd trypost +docker compose up --build +``` + +First boot takes ~3–4 minutes (composer install, npm ci, asset build, migrations). Subsequent boots are seconds. When the app container reports healthy, open . + +**Mail UI** at . **Vite HMR** on `:5173`. **Reverb websocket** on `:8080`. + +### Common commands + +```bash +# Artisan, Composer, Pest +docker compose exec app php artisan tinker +docker compose exec app php artisan test --compact +docker compose exec app composer require some/package + +# View logs (one stream per process) +docker compose logs -f app + +# Stop the stack +docker compose down + +# Wipe all data (db, redis, vendor, node_modules) +docker compose down -v +``` + +### Customizing your environment + +Compose merges `compose.override.yaml` (gitignored, personal) on top of the committed `compose.yaml`. Copy the example and edit: + +```bash +cp compose.override.yaml.example compose.override.yaml +``` + +Common tweaks (port forwarding, Xdebug, UID/GID alignment) are documented inline in that file. + +### Troubleshooting + +- **Port already in use** — set `APP_PORT`, `VITE_PORT`, `FORWARD_DB_PORT`, `FORWARD_MAILPIT_UI_PORT` in your shell or `.env`, or use `compose.override.yaml`. +- **Files written by container show as root-owned (Linux)** — pass your UID/GID at build time: + ```bash + UID=$(id -u) GID=$(id -g) docker compose up --build + ``` +- **Vite HMR not connecting** — confirm `:5173` is forwarded and not blocked by your firewall; check `docker compose logs app` for the Vite process output. +- **First boot is stuck** — composer install + npm ci run the first time only. Watch progress with `docker compose logs -f app`. Don't Ctrl-C in the first 3–4 minutes. +- **Reset everything** — `docker compose down -v && docker compose up --build` re-runs migrations and re-seeds dependencies. + +### Production image + +The same `docker/Dockerfile` exposes a `production` target for self-hosters: + +```bash +docker build --target production -t trypost:latest -f docker/Dockerfile . +``` + +The production image bakes in `composer install --no-dev`, `npm run build`, SSR build, and a hardened OpCache profile. Note that `VITE_REVERB_HOST` is baked into the JS bundle at image build time — set it for your deployment hostname before building. + ## Contributing We love contributions, no matter how small. Pick an [issue](https://github.com/trypost-it/trypost/issues), say hi in [Discussions](https://github.com/trypost-it/trypost/discussions), or just open a PR with what you'd like to see. From 7d3b8a478e0d2771159516d88bfadeb50abab9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Sun, 10 May 2026 20:29:48 -0300 Subject: [PATCH 05/65] fix(docker): drop stripe envs and bump upload limits to 1G MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two adjustments from the PR review. Stripe placeholders (STRIPE_KEY, STRIPE_SECRET, STRIPE_WEBHOOK_SECRET, and the eight price IDs) only matter when SELF_HOSTED=false. The Docker stack defaults to SELF_HOSTED=true, so keeping the block in .env.docker.example just adds noise for first-time contributors. upload_max_filesize and post_max_size go from 100M to 1G in both php.dev.ini and php.prod.ini to accept full-length video uploads. Bumping nginx client_max_body_size to match — otherwise the proxy would cut the request off before PHP ever sees it. --- docker/.env.docker.example | 14 -------------- docker/nginx.conf | 2 +- docker/php.dev.ini | 4 ++-- docker/php.prod.ini | 4 ++-- 4 files changed, 5 insertions(+), 19 deletions(-) diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 0fe8e7d6..aaacd5fd 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -137,20 +137,6 @@ AI_TEXT_MODEL=gpt-5.4 AI_IMAGE_PROVIDER=openai AI_AUDIO_PROVIDER=elevenlabs -# Stripe (only required when SELF_HOSTED=false) -STRIPE_KEY= -STRIPE_SECRET= -STRIPE_WEBHOOK_SECRET= - -STRIPE_STARTER_MONTHLY= -STRIPE_STARTER_YEARLY= -STRIPE_PLUS_MONTHLY= -STRIPE_PLUS_YEARLY= -STRIPE_PRO_MONTHLY= -STRIPE_PRO_YEARLY= -STRIPE_MAX_MONTHLY= -STRIPE_MAX_YEARLY= - # Telemetry — disabled by default in Docker dev. NIGHTWATCH_ENABLED=false NIGHTWATCH_TOKEN= diff --git a/docker/nginx.conf b/docker/nginx.conf index 0682a919..91442bc9 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -6,7 +6,7 @@ server { index index.php; charset utf-8; - client_max_body_size 100M; + client_max_body_size 1G; location / { try_files $uri $uri/ /index.php?$query_string; diff --git a/docker/php.dev.ini b/docker/php.dev.ini index 58eb5391..e13eaaa9 100644 --- a/docker/php.dev.ini +++ b/docker/php.dev.ini @@ -1,7 +1,7 @@ ; TryPost — PHP settings for local development. memory_limit = 512M -upload_max_filesize = 100M -post_max_size = 100M +upload_max_filesize = 1G +post_max_size = 1G max_execution_time = 120 max_input_vars = 5000 diff --git a/docker/php.prod.ini b/docker/php.prod.ini index 9152b763..8bca98c6 100644 --- a/docker/php.prod.ini +++ b/docker/php.prod.ini @@ -1,7 +1,7 @@ ; TryPost — PHP settings for production. memory_limit = 256M -upload_max_filesize = 100M -post_max_size = 100M +upload_max_filesize = 1G +post_max_size = 1G max_execution_time = 60 max_input_vars = 5000 From 33478468259ee01eb268ae78aca893c377e78e98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Sun, 10 May 2026 20:29:51 -0300 Subject: [PATCH 06/65] docs: remove Docker section from README User-facing documentation lives in trypostit/trypost-docs. Per maintainer feedback, the Docker setup walkthrough belongs there rather than inline in this repo's README, which is meant to stay focused on product positioning. Compose stack and docker/ assets stay in place. --- README.md | 63 ------------------------------------------------------- 1 file changed, 63 deletions(-) diff --git a/README.md b/README.md index e9e03bb1..3e96fdeb 100644 --- a/README.md +++ b/README.md @@ -99,69 +99,6 @@ ## Get Started | **Self-host** | Free forever. [Installation guide →](https://trypost.it/docs/getting-started/installation) | | **Run with AI assistants** | [Connect Claude / Cursor / ChatGPT via MCP →](https://trypost.it/docs/mcp) | -## Run with Docker (recommended for contributors) - -A single command brings up the full stack — Laravel app, Postgres, Redis, Mailpit, Reverb (websockets), Horizon (queue dashboard), and the scheduler — with no host PHP or Node prerequisites: - -```bash -git clone https://github.com/trypost-it/trypost.git -cd trypost -docker compose up --build -``` - -First boot takes ~3–4 minutes (composer install, npm ci, asset build, migrations). Subsequent boots are seconds. When the app container reports healthy, open . - -**Mail UI** at . **Vite HMR** on `:5173`. **Reverb websocket** on `:8080`. - -### Common commands - -```bash -# Artisan, Composer, Pest -docker compose exec app php artisan tinker -docker compose exec app php artisan test --compact -docker compose exec app composer require some/package - -# View logs (one stream per process) -docker compose logs -f app - -# Stop the stack -docker compose down - -# Wipe all data (db, redis, vendor, node_modules) -docker compose down -v -``` - -### Customizing your environment - -Compose merges `compose.override.yaml` (gitignored, personal) on top of the committed `compose.yaml`. Copy the example and edit: - -```bash -cp compose.override.yaml.example compose.override.yaml -``` - -Common tweaks (port forwarding, Xdebug, UID/GID alignment) are documented inline in that file. - -### Troubleshooting - -- **Port already in use** — set `APP_PORT`, `VITE_PORT`, `FORWARD_DB_PORT`, `FORWARD_MAILPIT_UI_PORT` in your shell or `.env`, or use `compose.override.yaml`. -- **Files written by container show as root-owned (Linux)** — pass your UID/GID at build time: - ```bash - UID=$(id -u) GID=$(id -g) docker compose up --build - ``` -- **Vite HMR not connecting** — confirm `:5173` is forwarded and not blocked by your firewall; check `docker compose logs app` for the Vite process output. -- **First boot is stuck** — composer install + npm ci run the first time only. Watch progress with `docker compose logs -f app`. Don't Ctrl-C in the first 3–4 minutes. -- **Reset everything** — `docker compose down -v && docker compose up --build` re-runs migrations and re-seeds dependencies. - -### Production image - -The same `docker/Dockerfile` exposes a `production` target for self-hosters: - -```bash -docker build --target production -t trypost:latest -f docker/Dockerfile . -``` - -The production image bakes in `composer install --no-dev`, `npm run build`, SSR build, and a hardened OpCache profile. Note that `VITE_REVERB_HOST` is baked into the JS bundle at image build time — set it for your deployment hostname before building. - ## Contributing We love contributions, no matter how small. Pick an [issue](https://github.com/trypost-it/trypost/issues), say hi in [Discussions](https://github.com/trypost-it/trypost/discussions), or just open a PR with what you'd like to see. From 1a74dfc60ed3f6415c324833d64162ba6ff9c338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Tue, 12 May 2026 13:56:57 -0300 Subject: [PATCH 07/65] fix(docker): production build/runtime fixes from homelab deploy - Add libwebp-dev + libavif-dev and --with-webp --with-avif to GD configure (fixes imagewebp() undefined at runtime) - Merge wayfinder-gen into asset-build stage: PHP + Node together so @laravel/vite-plugin-wayfinder can invoke artisan during build - Pass VITE_* as build args and ENV so they bake into the bundle - Stub APP_URL=http://localhost so artisan boot doesn't crash on null - mkdir -p storage/framework/{cache/data,sessions,views} storage/logs bootstrap/cache before artisan runs (dirs stripped by .dockerignore) - Production stage: rm vendor + bootstrap/cache/*.php before swapping to prod-only deps (prevents missing-provider errors from dev cache) - Switch production chown from app:app to www-data:www-data to match Alpine php-fpm default pool user --- docker/Dockerfile | 99 +++++++++++++++++++++++++++++--------------- docker/entrypoint.sh | 6 ++- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index 5025c384..3844c9d7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -29,12 +29,14 @@ RUN apk add --no-cache \ libpng-dev \ libjpeg-turbo-dev \ freetype-dev \ + libwebp-dev \ + libavif-dev \ libzip-dev \ oniguruma-dev \ icu-dev \ linux-headers \ $PHPIZE_DEPS \ - && docker-php-ext-configure gd --with-freetype --with-jpeg \ + && docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp --with-avif \ && docker-php-ext-install -j"$(nproc)" \ pdo_pgsql \ pgsql \ @@ -81,35 +83,61 @@ RUN composer install \ --no-interaction # ---------------------------------------------------------------------------- -# Stage 4: wayfinder-gen — generate TS route helpers (needs PHP + Laravel) +# Stage 4: asset-build — wayfinder TS gen + Vite + Inertia SSR build +# +# PHP and Node together in one stage because @laravel/vite-plugin-wayfinder +# invokes `php artisan wayfinder:generate` during vite build — splitting +# the stages would mean the vite phase has no PHP available. +# +# Vite inlines VITE_* values into the JS bundle at build time. Pass them +# as build args (--build-arg VITE_REVERB_APP_KEY=...) so the bundle reaches +# the browser with the correct Reverb/PostHog config. Runtime env vars on +# the container have no effect on the already-compiled bundle. # ---------------------------------------------------------------------------- -FROM composer-deps AS wayfinder-gen +FROM composer-deps AS asset-build + +# Node 22 for Vite + npm scripts. +RUN apk add --no-cache nodejs npm + +ARG VITE_APP_NAME=TryPost +ARG VITE_REVERB_APP_KEY= +ARG VITE_REVERB_HOST=localhost +ARG VITE_REVERB_PORT=8080 +ARG VITE_REVERB_SCHEME=http +ARG VITE_POSTHOG_ENABLED=false +ARG VITE_POSTHOG_API_KEY= +ARG VITE_POSTHOG_HOST=https://us.i.posthog.com + +# Stub PHP envs so artisan can boot (overridden at runtime). +ENV APP_KEY=base64:c3R1Yi13YXlmaW5kZXItZ2VuLWtleS1mb3ItYnVpbGRpbmctYXNzZXRzMA== \ + APP_ENV=production \ + APP_DEBUG=false \ + APP_URL=http://localhost \ + VITE_APP_NAME=${VITE_APP_NAME} \ + VITE_REVERB_APP_KEY=${VITE_REVERB_APP_KEY} \ + VITE_REVERB_HOST=${VITE_REVERB_HOST} \ + VITE_REVERB_PORT=${VITE_REVERB_PORT} \ + VITE_REVERB_SCHEME=${VITE_REVERB_SCHEME} \ + VITE_POSTHOG_ENABLED=${VITE_POSTHOG_ENABLED} \ + VITE_POSTHOG_API_KEY=${VITE_POSTHOG_API_KEY} \ + VITE_POSTHOG_HOST=${VITE_POSTHOG_HOST} COPY --from=composer-deps /var/www/html/vendor ./vendor COPY . . -# Stub env so artisan can boot; overridden at runtime. -ENV APP_KEY=base64:c3R1Yi13YXlmaW5kZXItZ2VuLWtleS1mb3ItYnVpbGRpbmctYXNzZXRzMA== \ - APP_ENV=production \ - APP_DEBUG=false +# Ensure Laravel runtime dirs exist — .dockerignore strips their contents +# (correct: caches shouldn't bloat builds), but artisan boot needs the dirs. +RUN mkdir -p storage/framework/cache/data \ + storage/framework/sessions \ + storage/framework/views \ + storage/logs \ + bootstrap/cache RUN composer dump-autoload --no-scripts --optimize \ - && php artisan wayfinder:generate --with-form - -# ---------------------------------------------------------------------------- -# Stage 5: asset-build — Vite + Inertia SSR build -# ---------------------------------------------------------------------------- -FROM node:${NODE_VERSION}-alpine AS asset-build - -WORKDIR /var/www/html - -COPY package.json package-lock.json ./ -RUN npm ci --no-audit --no-fund - -# Pull source + freshly generated wayfinder TS from previous stage. -COPY --from=wayfinder-gen /var/www/html /var/www/html - -RUN npm run build && npm run build:ssr + && php artisan wayfinder:generate --with-form \ + && npm ci --no-audit --no-fund \ + && npm run build \ + && npm run build:ssr # ---------------------------------------------------------------------------- # Stage 6: dev — local development image (bind-mount the source at runtime) @@ -148,9 +176,9 @@ ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] # ---------------------------------------------------------------------------- FROM system-base AS production -# Non-root runtime user (fixed UID; overridable per orchestrator if needed). -RUN addgroup -g 1000 -S app \ - && adduser -u 1000 -S app -G app -h /home/app +# php-fpm runs as www-data (Alpine default pool config) — that's the user +# storage and bootstrap/cache must be owned by, so Laravel can write logs, +# session files, view cache, etc. COPY docker/nginx.conf /etc/nginx/http.d/default.conf COPY docker/php.prod.ini /usr/local/etc/php/conf.d/99-trypost.ini @@ -158,16 +186,21 @@ COPY docker/supervisord.prod.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh -# Production composer deps only. +# Application source + generated wayfinder TS + built assets — all from +# asset-build (the merged stage that ran php artisan wayfinder:generate, +# npm run build, and npm run build:ssr). +COPY --from=asset-build /var/www/html /var/www/html + +# Replace the dev-equipped vendor/ with production-only deps. composer-deps-prod +# is built independently and is much smaller (no phpunit, no debugbar, no pail, +# no pint). Also wipe bootstrap/cache/*.php — package:discover ran with dev +# deps and recorded providers (Pail, Telescope etc) that no longer exist; +# Laravel rediscovers cleanly at first boot. +RUN rm -rf /var/www/html/vendor /var/www/html/bootstrap/cache/*.php COPY --from=composer-deps-prod /var/www/html/vendor /var/www/html/vendor -# Application source + generated wayfinder TS + built assets. -COPY --from=wayfinder-gen /var/www/html /var/www/html -COPY --from=asset-build /var/www/html/public/build /var/www/html/public/build -COPY --from=asset-build /var/www/html/bootstrap/ssr /var/www/html/bootstrap/ssr - RUN composer dump-autoload --optimize --classmap-authoritative --no-scripts \ - && chown -R app:app /var/www/html/storage /var/www/html/bootstrap/cache + && chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache ENV TRYPOST_TARGET=production diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 56d24bfd..23aa100f 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -96,9 +96,11 @@ else php artisan event:clear fi -# 12) Permissions. +# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default), +# so storage and bootstrap/cache must be writable by that user — Laravel +# needs to write session files, view cache, log files, etc. if [ "${TARGET}" = "production" ]; then - chown -R app:app storage bootstrap/cache + chown -R www-data:www-data storage bootstrap/cache else # Dev: ensure UID-mapped user owns runtime dirs. APP_UID="${UID:-1000}" From ef501dd385c2ef5b68c829d7cc50b9ef5280139a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Dantas?= Date: Tue, 12 May 2026 13:57:05 -0300 Subject: [PATCH 08/65] fix(docker): add Reverb WebSocket proxy to nginx Add /app/ (Pusher protocol) and /apps/ (REST API) location blocks with Upgrade/Connection headers so browsers can connect to the in-container Reverb server via the same port 80/443 endpoint. --- docker/nginx.conf | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docker/nginx.conf b/docker/nginx.conf index 91442bc9..62503040 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -8,6 +8,31 @@ server { charset utf-8; client_max_body_size 1G; + # Reverb WebSocket (Pusher protocol). The browser connects to + # wss:///app/; the broadcaster REST API lives under /apps/. + # Both are proxied to the in-container Reverb on 127.0.0.1:8080. + location /app/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + } + + location /apps/ { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location / { try_files $uri $uri/ /index.php?$query_string; } From 99f1f7ed84e6a862dffe4ec3a1e52524d1a4f635 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:02:46 -0300 Subject: [PATCH 09/65] feat(media): add upload_token column for MCP signed uploads --- app/Models/Media.php | 1 + ...190137_add_upload_token_to_media_table.php | 25 +++++++++++++++++++ .../Database/MediaUploadTokenColumnTest.php | 9 +++++++ 3 files changed, 35 insertions(+) create mode 100644 database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php create mode 100644 tests/Feature/Database/MediaUploadTokenColumnTest.php diff --git a/app/Models/Media.php b/app/Models/Media.php index 93667ada..f60f24fb 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -34,6 +34,7 @@ class Media extends Model 'size', 'order', 'meta', + 'upload_token', ]; protected function casts(): array diff --git a/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php b/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php new file mode 100644 index 00000000..925b4e5e --- /dev/null +++ b/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php @@ -0,0 +1,25 @@ +uuid('upload_token')->nullable()->unique()->after('id'); + }); + } + + public function down(): void + { + Schema::table('medias', function (Blueprint $table) { + $table->dropUnique(['upload_token']); + $table->dropColumn('upload_token'); + }); + } +}; diff --git a/tests/Feature/Database/MediaUploadTokenColumnTest.php b/tests/Feature/Database/MediaUploadTokenColumnTest.php new file mode 100644 index 00000000..3d87d2ea --- /dev/null +++ b/tests/Feature/Database/MediaUploadTokenColumnTest.php @@ -0,0 +1,9 @@ +toBeTrue(); +}); From 5408ca7e274b8079f393dd9fb7622268163b60ab Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:06:54 -0300 Subject: [PATCH 10/65] style(media): add void return on migration closures, drop noop ->after --- .../2026_05_15_190137_add_upload_token_to_media_table.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php b/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php index 925b4e5e..220565a9 100644 --- a/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php +++ b/database/migrations/2026_05_15_190137_add_upload_token_to_media_table.php @@ -10,14 +10,14 @@ { public function up(): void { - Schema::table('medias', function (Blueprint $table) { - $table->uuid('upload_token')->nullable()->unique()->after('id'); + Schema::table('medias', function (Blueprint $table): void { + $table->uuid('upload_token')->nullable()->unique(); }); } public function down(): void { - Schema::table('medias', function (Blueprint $table) { + Schema::table('medias', function (Blueprint $table): void { $table->dropUnique(['upload_token']); $table->dropColumn('upload_token'); }); From b17026f3a1f25acf14b34a1532a375a44843e196 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:10:21 -0300 Subject: [PATCH 11/65] feat(media): signed POST upload endpoint for MCP flow --- app/Http/Controllers/Api/UploadController.php | 44 +++++++ app/Http/Requests/Api/StoreUploadRequest.php | 33 +++++ routes/api.php | 6 + tests/Feature/Api/UploadControllerTest.php | 118 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 app/Http/Controllers/Api/UploadController.php create mode 100644 app/Http/Requests/Api/StoreUploadRequest.php create mode 100644 tests/Feature/Api/UploadControllerTest.php diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php new file mode 100644 index 00000000..84b122c4 --- /dev/null +++ b/app/Http/Controllers/Api/UploadController.php @@ -0,0 +1,44 @@ +query('expires'); + $ttl = max(60, $expiresAt - now()->timestamp + 60); + + if (! Cache::add("mcp_upload:{$token}", true, $ttl)) { + abort(Response::HTTP_CONFLICT, 'Upload token already used.'); + } + + if (Media::where('upload_token', $token)->exists()) { + abort(Response::HTTP_CONFLICT, 'Upload token already consumed.'); + } + + $workspace = Workspace::findOrFail((string) $request->query('ws')); + + $media = $workspace->addMedia($request->file('media'), 'assets'); + $media->upload_token = $token; + $media->save(); + + return response()->json([ + 'upload_token' => $token, + 'media_id' => $media->id, + 'type' => $media->type, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + ], Response::HTTP_CREATED); + } +} diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php new file mode 100644 index 00000000..f21caf5c --- /dev/null +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -0,0 +1,33 @@ +allowedMimeTypes(), + MediaType::Video->allowedMimeTypes(), + )); + + return [ + 'media' => [ + 'required', + 'file', + 'max:51200', + "mimetypes:{$allowed}", + ], + ]; + } +} diff --git a/routes/api.php b/routes/api.php index 3484ae9b..c6f5a67f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,9 +8,15 @@ use App\Http\Controllers\Api\PostController; use App\Http\Controllers\Api\SignatureController; use App\Http\Controllers\Api\SocialAccountController; +use App\Http\Controllers\Api\UploadController; use App\Http\Controllers\Api\WorkspaceController; use Illuminate\Support\Facades\Route; +Route::post('/uploads/{token}', [UploadController::class, 'store']) + ->middleware('signed') + ->where('token', '[0-9a-f-]{36}') + ->name('api.uploads.store'); + Route::middleware(['auth:api', 'workspace.token', 'throttle:api'])->group(function () { // Posts Route::get('/posts', [PostController::class, 'index'])->name('api.posts.index'); diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php new file mode 100644 index 00000000..90082581 --- /dev/null +++ b/tests/Feature/Api/UploadControllerTest.php @@ -0,0 +1,118 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); +}); + +function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = 15): string +{ + return URL::temporarySignedRoute( + 'api.uploads.store', + now()->addMinutes($expiresInMinutes), + ['token' => $token, 'ws' => $ws->id], + ); +} + +test('valid signed POST stores Media with upload_token', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->image('shot.png', 50, 50); + + $response = $this->post(signedUploadUrl($this->workspace, $token), [ + 'media' => $file, + ]); + + $response->assertCreated() + ->assertJsonStructure(['upload_token', 'media_id', 'type', 'mime_type', 'original_filename']); + + $media = Media::where('upload_token', $token)->first(); + expect($media)->not->toBeNull(); + expect($media->mediable_id)->toBe($this->workspace->id); + expect($media->mediable_type)->toBe('workspace'); +}); + +test('rejects unsigned request', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->image('shot.png', 50, 50); + + $response = $this->postJson(route('api.uploads.store', ['token' => $token, 'ws' => $this->workspace->id]), [ + 'media' => $file, + ]); + + $response->assertForbidden(); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + +test('rejects tampered workspace_id', function () { + $other = Workspace::factory()->create(); + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->image('shot.png', 50, 50); + + $url = signedUploadUrl($this->workspace, $token); + $tampered = str_replace("ws={$this->workspace->id}", "ws={$other->id}", $url); + + $response = $this->postJson($tampered, ['media' => $file]); + + $response->assertForbidden(); +}); + +test('rejects expired URL', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->image('shot.png', 50, 50); + + $url = URL::temporarySignedRoute( + 'api.uploads.store', + now()->subMinute(), + ['token' => $token, 'ws' => $this->workspace->id], + ); + + $response = $this->postJson($url, ['media' => $file]); + $response->assertForbidden(); +}); + +test('rejects replay of an already-used token', function () { + $token = (string) Str::uuid(); + $file1 = UploadedFile::fake()->image('one.png', 50, 50); + $file2 = UploadedFile::fake()->image('two.png', 50, 50); + + $this->post(signedUploadUrl($this->workspace, $token), ['media' => $file1])->assertCreated(); + $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file2])->assertStatus(409); + + expect(Media::where('upload_token', $token)->count())->toBe(1); +}); + +test('rejects file larger than 50MB', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('huge.mp4', 51 * 1024 + 1, 'video/mp4'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); + +test('rejects disallowed mime type', function () { + $token = (string) Str::uuid(); + $file = UploadedFile::fake()->create('evil.exe', 10, 'application/octet-stream'); + + $response = $this->postJson(signedUploadUrl($this->workspace, $token), ['media' => $file]); + + $response->assertStatus(422); + expect(Media::where('upload_token', $token)->exists())->toBeFalse(); +}); From d50348562b65689ad5c3522c83c9bac1e5080e8a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:16:42 -0300 Subject: [PATCH 12/65] refactor(media): atomic upload_token via transaction and tighten test assertions --- app/Http/Controllers/Api/UploadController.php | 11 ++++++++--- app/Http/Requests/Api/StoreUploadRequest.php | 3 +++ tests/Feature/Api/UploadControllerTest.php | 11 ++++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index 84b122c4..f5b63daf 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -10,6 +10,7 @@ use App\Models\Workspace; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Symfony\Component\HttpFoundation\Response; class UploadController extends Controller @@ -29,9 +30,13 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse $workspace = Workspace::findOrFail((string) $request->query('ws')); - $media = $workspace->addMedia($request->file('media'), 'assets'); - $media->upload_token = $token; - $media->save(); + $media = DB::transaction(function () use ($workspace, $request, $token): Media { + $media = $workspace->addMedia($request->file('media'), 'assets'); + $media->upload_token = $token; + $media->save(); + + return $media; + }); return response()->json([ 'upload_token' => $token, diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index f21caf5c..11706af5 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -14,6 +14,9 @@ public function authorize(): bool return true; } + /** + * @return array + */ public function rules(): array { $allowed = implode(',', array_merge( diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index 90082581..bee8a9ed 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -38,13 +38,18 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = 'media' => $file, ]); - $response->assertCreated() - ->assertJsonStructure(['upload_token', 'media_id', 'type', 'mime_type', 'original_filename']); - $media = Media::where('upload_token', $token)->first(); expect($media)->not->toBeNull(); expect($media->mediable_id)->toBe($this->workspace->id); expect($media->mediable_type)->toBe('workspace'); + + $response->assertCreated() + ->assertJson([ + 'upload_token' => $token, + 'media_id' => $media->id, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + ]); }); test('rejects unsigned request', function () { From 7971571dc3967fdddfc8846c698b766d1e8baaf2 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:19:03 -0300 Subject: [PATCH 13/65] feat(mcp): RequestMediaUploadTool issues signed upload URLs --- app/Mcp/Servers/TryPostServer.php | 2 + app/Mcp/Tools/Post/RequestMediaUploadTool.php | 47 ++++++++++++ .../Mcp/RequestMediaUploadToolTest.php | 72 +++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 app/Mcp/Tools/Post/RequestMediaUploadTool.php create mode 100644 tests/Feature/Mcp/RequestMediaUploadToolTest.php diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index 6bd9df35..d61aa92c 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -20,6 +20,7 @@ use App\Mcp\Tools\Post\ListPostsTool; use App\Mcp\Tools\Post\PreviewPostTool; use App\Mcp\Tools\Post\PublishPostTool; +use App\Mcp\Tools\Post\RequestMediaUploadTool; use App\Mcp\Tools\Post\UpdatePostTool; use App\Mcp\Tools\Signature\CreateSignatureTool; use App\Mcp\Tools\Signature\DeleteSignatureTool; @@ -50,6 +51,7 @@ class TryPostServer extends Server PreviewPostTool::class, DeletePostTool::class, AttachMediaFromUrlTool::class, + RequestMediaUploadTool::class, GetPostMetricsTool::class, // Platforms (read-only metadata) diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php new file mode 100644 index 00000000..e61dcd84 --- /dev/null +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -0,0 +1,47 @@ +user(); + $workspaceId = $user->current_workspace_id; + + $token = (string) Str::uuid(); + $expiresAt = CarbonImmutable::now()->addMinutes(15); + + $uploadUrl = URL::temporarySignedRoute( + 'api.uploads.store', + $expiresAt, + ['token' => $token, 'ws' => $workspaceId], + ); + + return Response::structured([ + 'upload_token' => $token, + 'upload_url' => $uploadUrl, + 'expires_at' => $expiresAt->toIso8601String(), + 'max_bytes' => 52428800, + 'field_name' => 'media', + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } +} diff --git a/tests/Feature/Mcp/RequestMediaUploadToolTest.php b/tests/Feature/Mcp/RequestMediaUploadToolTest.php new file mode 100644 index 00000000..8ce7afbc --- /dev/null +++ b/tests/Feature/Mcp/RequestMediaUploadToolTest.php @@ -0,0 +1,72 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); +}); + +test('returns a single-use signed upload URL', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(RequestMediaUploadTool::class, []); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->has('upload_token') + ->has('upload_url') + ->has('expires_at') + ->where('max_bytes', 52428800) + ->where('field_name', 'media') + ->etc(); + }); +}); + +test('signed URL is valid against the api.uploads.store route', function () { + $uploadUrl = null; + + TryPostServer::actingAs($this->user) + ->tool(RequestMediaUploadTool::class, []) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use (&$uploadUrl) { + $json->etc(); + $uploadUrl = $json->toArray()['upload_url']; + }); + + expect(URL::hasValidSignature( + request()->create($uploadUrl, 'POST'), + ))->toBeTrue(); +}); + +test('each call returns a distinct upload_token', function () { + $firstToken = null; + $secondToken = null; + + TryPostServer::actingAs($this->user) + ->tool(RequestMediaUploadTool::class, []) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use (&$firstToken) { + $json->etc(); + $firstToken = $json->toArray()['upload_token']; + }); + + TryPostServer::actingAs($this->user) + ->tool(RequestMediaUploadTool::class, []) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use (&$secondToken) { + $json->etc(); + $secondToken = $json->toArray()['upload_token']; + }); + + expect($firstToken)->not->toBe($secondToken); +}); From 6b40edce55971218bfdb8b8bb5360fea5e9546c5 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:24:14 -0300 Subject: [PATCH 14/65] refactor(mcp): extract MCP upload caps to trypost.mcp.upload config --- app/Http/Requests/Api/StoreUploadRequest.php | 4 +++- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 9 ++++++--- config/trypost.php | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index 11706af5..df5a1c3d 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -24,11 +24,13 @@ public function rules(): array MediaType::Video->allowedMimeTypes(), )); + $maxKb = (int) config('trypost.mcp.upload.max_size_mb') * 1024; + return [ 'media' => [ 'required', 'file', - 'max:51200', + "max:{$maxKb}", "mimetypes:{$allowed}", ], ]; diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index e61dcd84..b455ccd4 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -14,7 +14,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image up to 10 MB, video up to 50 MB hard cap) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] +#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image or video, up to the workspace upload cap — 50 MB by default) directly to this workspace. Returns an upload_token and upload_url. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')] class RequestMediaUploadTool extends Tool { public function handle(Request $request): Response|ResponseFactory @@ -22,8 +22,11 @@ public function handle(Request $request): Response|ResponseFactory $user = $request->user(); $workspaceId = $user->current_workspace_id; + $maxBytes = (int) config('trypost.mcp.upload.max_size_mb') * 1024 * 1024; + $ttlMinutes = (int) config('trypost.mcp.upload.url_ttl_minutes'); + $token = (string) Str::uuid(); - $expiresAt = CarbonImmutable::now()->addMinutes(15); + $expiresAt = CarbonImmutable::now()->addMinutes($ttlMinutes); $uploadUrl = URL::temporarySignedRoute( 'api.uploads.store', @@ -35,7 +38,7 @@ public function handle(Request $request): Response|ResponseFactory 'upload_token' => $token, 'upload_url' => $uploadUrl, 'expires_at' => $expiresAt->toIso8601String(), - 'max_bytes' => 52428800, + 'max_bytes' => $maxBytes, 'field_name' => 'media', ]); } diff --git a/config/trypost.php b/config/trypost.php index 9ab2e2b9..2280ac2e 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -34,6 +34,24 @@ ], ], + /* + |-------------------------------------------------------------------------- + | MCP Media Upload + |-------------------------------------------------------------------------- + | + | Settings for the MCP signed-URL upload flow. The hard cap is enforced + | per upload regardless of media type and is intentionally smaller than + | the per-type caps above because uploads flow through the app server. + | + */ + + 'mcp' => [ + 'upload' => [ + 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), + 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), + ], + ], + /* |-------------------------------------------------------------------------- | Google Authentication From 7341852f5c4aadb5ee017fd72ea5d756c00b9c60 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:25:26 -0300 Subject: [PATCH 15/65] refactor(mcp): move upload config from trypost.mcp to ai.mcp --- app/Http/Requests/Api/StoreUploadRequest.php | 2 +- app/Mcp/Tools/Post/RequestMediaUploadTool.php | 4 ++-- config/ai.php | 19 +++++++++++++++++++ config/trypost.php | 18 ------------------ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/app/Http/Requests/Api/StoreUploadRequest.php b/app/Http/Requests/Api/StoreUploadRequest.php index df5a1c3d..812c8ac9 100644 --- a/app/Http/Requests/Api/StoreUploadRequest.php +++ b/app/Http/Requests/Api/StoreUploadRequest.php @@ -24,7 +24,7 @@ public function rules(): array MediaType::Video->allowedMimeTypes(), )); - $maxKb = (int) config('trypost.mcp.upload.max_size_mb') * 1024; + $maxKb = (int) config('ai.mcp.upload.max_size_mb') * 1024; return [ 'media' => [ diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index b455ccd4..354e9100 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -22,8 +22,8 @@ public function handle(Request $request): Response|ResponseFactory $user = $request->user(); $workspaceId = $user->current_workspace_id; - $maxBytes = (int) config('trypost.mcp.upload.max_size_mb') * 1024 * 1024; - $ttlMinutes = (int) config('trypost.mcp.upload.url_ttl_minutes'); + $maxBytes = (int) config('ai.mcp.upload.max_size_mb') * 1024 * 1024; + $ttlMinutes = (int) config('ai.mcp.upload.url_ttl_minutes'); $token = (string) Str::uuid(); $expiresAt = CarbonImmutable::now()->addMinutes($ttlMinutes); diff --git a/config/ai.php b/config/ai.php index 1a393ff0..c28a3160 100644 --- a/config/ai.php +++ b/config/ai.php @@ -39,6 +39,25 @@ ], ], + /* + |-------------------------------------------------------------------------- + | MCP Media Upload + |-------------------------------------------------------------------------- + | + | Settings for the MCP signed-URL upload flow. The hard cap is enforced + | per upload regardless of media type and is intentionally smaller than + | the per-type caps in config('trypost.media') because uploads flow + | through the app server. + | + */ + + 'mcp' => [ + 'upload' => [ + 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), + 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), + ], + ], + /* |-------------------------------------------------------------------------- | AI Providers diff --git a/config/trypost.php b/config/trypost.php index 2280ac2e..9ab2e2b9 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -34,24 +34,6 @@ ], ], - /* - |-------------------------------------------------------------------------- - | MCP Media Upload - |-------------------------------------------------------------------------- - | - | Settings for the MCP signed-URL upload flow. The hard cap is enforced - | per upload regardless of media type and is intentionally smaller than - | the per-type caps above because uploads flow through the app server. - | - */ - - 'mcp' => [ - 'upload' => [ - 'max_size_mb' => (int) env('MCP_UPLOAD_MAX_SIZE_MB', 50), - 'url_ttl_minutes' => (int) env('MCP_UPLOAD_URL_TTL_MINUTES', 15), - ], - ], - /* |-------------------------------------------------------------------------- | Google Authentication From dd1056a41314f40c5c4ce73e0129cf08dd51d285 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:29:05 -0300 Subject: [PATCH 16/65] feat(mcp): AttachMediaFromUploadTool attaches uploaded Media by token --- app/Mcp/Servers/TryPostServer.php | 2 + .../Tools/Post/AttachMediaFromUploadTool.php | 74 +++++++++++++++ .../Mcp/AttachMediaFromUploadToolTest.php | 92 +++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 app/Mcp/Tools/Post/AttachMediaFromUploadTool.php create mode 100644 tests/Feature/Mcp/AttachMediaFromUploadToolTest.php diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index d61aa92c..cd2d1850 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -12,6 +12,7 @@ use App\Mcp\Tools\Label\ListLabelsTool; use App\Mcp\Tools\Label\UpdateLabelTool; use App\Mcp\Tools\Platform\ListContentTypesTool; +use App\Mcp\Tools\Post\AttachMediaFromUploadTool; use App\Mcp\Tools\Post\AttachMediaFromUrlTool; use App\Mcp\Tools\Post\CreatePostTool; use App\Mcp\Tools\Post\DeletePostTool; @@ -52,6 +53,7 @@ class TryPostServer extends Server DeletePostTool::class, AttachMediaFromUrlTool::class, RequestMediaUploadTool::class, + AttachMediaFromUploadTool::class, GetPostMetricsTool::class, // Platforms (read-only metadata) diff --git a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php new file mode 100644 index 00000000..520c0eb7 --- /dev/null +++ b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php @@ -0,0 +1,74 @@ +validate([ + 'post_id' => ['required', 'uuid'], + 'upload_token' => ['required', 'uuid'], + ]); + + $workspaceId = $request->user()->current_workspace_id; + + $post = Post::where('workspace_id', $workspaceId) + ->find(data_get($validated, 'post_id')); + + if (! $post) { + return Response::error('Post not found.'); + } + + $media = Media::query() + ->where('upload_token', data_get($validated, 'upload_token')) + ->where('mediable_type', Workspace::class) + ->where('mediable_id', $workspaceId) + ->first(); + + if (! $media) { + return Response::error('Upload not found.'); + } + + if (! in_array($media->type, $post->allowedMediaTypes(), true)) { + return Response::error('No enabled platform on this post accepts this media type.'); + } + + $post->appendMedia([[ + 'id' => $media->id, + 'path' => $media->path, + 'url' => $media->url, + 'type' => $media->type, + 'mime_type' => $media->mime_type, + 'original_filename' => $media->original_filename, + ]]); + + $post->refresh()->load(['postPlatforms.socialAccount', 'labels']); + + return Response::structured([ + 'post' => (new PostResource($post))->resolve(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('UUID of the post to attach the uploaded media to.'), + 'upload_token' => $schema->string()->required()->description('upload_token returned by RequestMediaUploadTool, after the user has POSTed the file to the upload_url.'), + ]; + } +} diff --git a/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php b/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php new file mode 100644 index 00000000..e4481145 --- /dev/null +++ b/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php @@ -0,0 +1,92 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + + $this->token = (string) Str::uuid(); + $this->media = Media::factory()->create([ + 'mediable_type' => Workspace::class, + 'mediable_id' => $this->workspace->id, + 'collection' => 'assets', + 'upload_token' => $this->token, + ]); +}); + +test('attaches the uploaded Media to the post', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $this->post->id, + 'upload_token' => $this->token, + ]); + + $response->assertOk(); + expect($this->post->fresh()->media)->toHaveCount(1); +}); + +test('rejects a token from a different workspace', function () { + $other = User::factory()->create(); + $otherWs = Workspace::factory()->create(['user_id' => $other->id]); + + $foreignToken = (string) Str::uuid(); + Media::factory()->create([ + 'mediable_type' => Workspace::class, + 'mediable_id' => $otherWs->id, + 'collection' => 'assets', + 'upload_token' => $foreignToken, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $this->post->id, + 'upload_token' => $foreignToken, + ]); + + $response->assertHasErrors(); + expect($this->post->fresh()->media)->toHaveCount(0); +}); + +test('rejects an unknown upload_token', function () { + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $this->post->id, + 'upload_token' => (string) Str::uuid(), + ]); + + $response->assertHasErrors(); +}); + +test('rejects a post from another workspace', function () { + $other = User::factory()->create(); + $otherWs = Workspace::factory()->create(['user_id' => $other->id]); + $otherPost = Post::factory()->create([ + 'workspace_id' => $otherWs->id, + 'user_id' => $other->id, + ]); + + $response = TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $otherPost->id, + 'upload_token' => $this->token, + ]); + + $response->assertHasErrors(); +}); From 41a704dc6af11ed255b2ef5741f1d6c5545decf4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:34:25 -0300 Subject: [PATCH 17/65] fix(mcp): use morph alias for mediable_type to match production storage --- app/Mcp/Tools/Post/AttachMediaFromUploadTool.php | 2 +- tests/Feature/Mcp/AttachMediaFromUploadToolTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php index 520c0eb7..dcebfe95 100644 --- a/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php +++ b/app/Mcp/Tools/Post/AttachMediaFromUploadTool.php @@ -36,7 +36,7 @@ public function handle(Request $request): Response|ResponseFactory $media = Media::query() ->where('upload_token', data_get($validated, 'upload_token')) - ->where('mediable_type', Workspace::class) + ->where('mediable_type', (new Workspace)->getMorphClass()) ->where('mediable_id', $workspaceId) ->first(); diff --git a/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php b/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php index e4481145..fa3560ba 100644 --- a/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php +++ b/tests/Feature/Mcp/AttachMediaFromUploadToolTest.php @@ -24,7 +24,7 @@ $this->token = (string) Str::uuid(); $this->media = Media::factory()->create([ - 'mediable_type' => Workspace::class, + 'mediable_type' => (new Workspace)->getMorphClass(), 'mediable_id' => $this->workspace->id, 'collection' => 'assets', 'upload_token' => $this->token, @@ -48,7 +48,7 @@ $foreignToken = (string) Str::uuid(); Media::factory()->create([ - 'mediable_type' => Workspace::class, + 'mediable_type' => (new Workspace)->getMorphClass(), 'mediable_id' => $otherWs->id, 'collection' => 'assets', 'upload_token' => $foreignToken, From 98b3a9ffb7fc032272f3962b8c5a098a3b4ace0d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:37:40 -0300 Subject: [PATCH 18/65] test(mcp): end-to-end media upload flow --- tests/Feature/Mcp/MediaUploadFlowTest.php | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/Feature/Mcp/MediaUploadFlowTest.php diff --git a/tests/Feature/Mcp/MediaUploadFlowTest.php b/tests/Feature/Mcp/MediaUploadFlowTest.php new file mode 100644 index 00000000..65fbbe74 --- /dev/null +++ b/tests/Feature/Mcp/MediaUploadFlowTest.php @@ -0,0 +1,59 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); + $this->user->update(['current_workspace_id' => $this->workspace->id]); + + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); +}); + +test('agent can request URL, client uploads, agent attaches to post', function () { + $uploadUrl = null; + $uploadToken = null; + + TryPostServer::actingAs($this->user) + ->tool(RequestMediaUploadTool::class, []) + ->assertOk() + ->assertStructuredContent(function (AssertableJson $json) use (&$uploadUrl, &$uploadToken) { + $json->etc(); + $uploadUrl = $json->toArray()['upload_url']; + $uploadToken = $json->toArray()['upload_token']; + }); + + expect($uploadUrl)->not->toBeNull(); + expect($uploadToken)->not->toBeNull(); + + $file = UploadedFile::fake()->image('e2e.png', 64, 64); + $this->post($uploadUrl, ['media' => $file])->assertCreated(); + + TryPostServer::actingAs($this->user) + ->tool(AttachMediaFromUploadTool::class, [ + 'post_id' => $this->post->id, + 'upload_token' => $uploadToken, + ]) + ->assertOk(); + + expect($this->post->fresh()->media)->toHaveCount(1); +}); From 9845d15db3a1f4e9dd59c17b43380090deca2c93 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 16:48:09 -0300 Subject: [PATCH 19/65] =?UTF-8?q?refactor(media):=20MediaUploadResource=20?= =?UTF-8?q?+=20ws=E2=86=92workspace=5Fid=20+=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Api/UploadController.php | 24 +++++++------- .../Resources/Api/MediaUploadResource.php | 31 +++++++++++++++++++ app/Mcp/Tools/Post/RequestMediaUploadTool.php | 2 +- tests/Feature/Api/UploadControllerTest.php | 8 ++--- 4 files changed, 49 insertions(+), 16 deletions(-) create mode 100644 app/Http/Resources/Api/MediaUploadResource.php diff --git a/app/Http/Controllers/Api/UploadController.php b/app/Http/Controllers/Api/UploadController.php index f5b63daf..e6104975 100644 --- a/app/Http/Controllers/Api/UploadController.php +++ b/app/Http/Controllers/Api/UploadController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Api\StoreUploadRequest; +use App\Http\Resources\Api\MediaUploadResource; use App\Models\Media; use App\Models\Workspace; use Illuminate\Http\JsonResponse; @@ -15,20 +16,25 @@ class UploadController extends Controller { + private const CACHE_TTL_BUFFER_SECONDS = 60; + public function store(StoreUploadRequest $request, string $token): JsonResponse { $expiresAt = (int) $request->query('expires'); - $ttl = max(60, $expiresAt - now()->timestamp + 60); + $ttl = max( + self::CACHE_TTL_BUFFER_SECONDS, + $expiresAt - now()->timestamp + self::CACHE_TTL_BUFFER_SECONDS, + ); if (! Cache::add("mcp_upload:{$token}", true, $ttl)) { - abort(Response::HTTP_CONFLICT, 'Upload token already used.'); + abort(Response::HTTP_CONFLICT); } if (Media::where('upload_token', $token)->exists()) { - abort(Response::HTTP_CONFLICT, 'Upload token already consumed.'); + abort(Response::HTTP_CONFLICT); } - $workspace = Workspace::findOrFail((string) $request->query('ws')); + $workspace = Workspace::findOrFail((string) $request->query('workspace_id')); $media = DB::transaction(function () use ($workspace, $request, $token): Media { $media = $workspace->addMedia($request->file('media'), 'assets'); @@ -38,12 +44,8 @@ public function store(StoreUploadRequest $request, string $token): JsonResponse return $media; }); - return response()->json([ - 'upload_token' => $token, - 'media_id' => $media->id, - 'type' => $media->type, - 'mime_type' => $media->mime_type, - 'original_filename' => $media->original_filename, - ], Response::HTTP_CREATED); + return MediaUploadResource::make($media) + ->response() + ->setStatusCode(Response::HTTP_CREATED); } } diff --git a/app/Http/Resources/Api/MediaUploadResource.php b/app/Http/Resources/Api/MediaUploadResource.php new file mode 100644 index 00000000..a3898d96 --- /dev/null +++ b/app/Http/Resources/Api/MediaUploadResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'upload_token' => $this->upload_token, + 'media_id' => $this->id, + 'type' => $this->type, + 'mime_type' => $this->mime_type, + 'original_filename' => $this->original_filename, + ]; + } +} diff --git a/app/Mcp/Tools/Post/RequestMediaUploadTool.php b/app/Mcp/Tools/Post/RequestMediaUploadTool.php index 354e9100..fb17667b 100644 --- a/app/Mcp/Tools/Post/RequestMediaUploadTool.php +++ b/app/Mcp/Tools/Post/RequestMediaUploadTool.php @@ -31,7 +31,7 @@ public function handle(Request $request): Response|ResponseFactory $uploadUrl = URL::temporarySignedRoute( 'api.uploads.store', $expiresAt, - ['token' => $token, 'ws' => $workspaceId], + ['token' => $token, 'workspace_id' => $workspaceId], ); return Response::structured([ diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index bee8a9ed..2d79584a 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -26,7 +26,7 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = return URL::temporarySignedRoute( 'api.uploads.store', now()->addMinutes($expiresInMinutes), - ['token' => $token, 'ws' => $ws->id], + ['token' => $token, 'workspace_id' => $ws->id], ); } @@ -56,7 +56,7 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = $token = (string) Str::uuid(); $file = UploadedFile::fake()->image('shot.png', 50, 50); - $response = $this->postJson(route('api.uploads.store', ['token' => $token, 'ws' => $this->workspace->id]), [ + $response = $this->postJson(route('api.uploads.store', ['token' => $token, 'workspace_id' => $this->workspace->id]), [ 'media' => $file, ]); @@ -70,7 +70,7 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = $file = UploadedFile::fake()->image('shot.png', 50, 50); $url = signedUploadUrl($this->workspace, $token); - $tampered = str_replace("ws={$this->workspace->id}", "ws={$other->id}", $url); + $tampered = str_replace("workspace_id={$this->workspace->id}", "workspace_id={$other->id}", $url); $response = $this->postJson($tampered, ['media' => $file]); @@ -84,7 +84,7 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = $url = URL::temporarySignedRoute( 'api.uploads.store', now()->subMinute(), - ['token' => $token, 'ws' => $this->workspace->id], + ['token' => $token, 'workspace_id' => $this->workspace->id], ); $response = $this->postJson($url, ['media' => $file]); From 7fac3aaf0fe71f643399e161459474b9c2fd09df Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Fri, 15 May 2026 17:09:40 -0300 Subject: [PATCH 20/65] feat(media): rate-limit upload endpoint to 10 req/min/IP --- routes/api.php | 2 +- tests/Feature/Api/UploadControllerTest.php | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/routes/api.php b/routes/api.php index c6f5a67f..fe3c48f1 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,7 +13,7 @@ use Illuminate\Support\Facades\Route; Route::post('/uploads/{token}', [UploadController::class, 'store']) - ->middleware('signed') + ->middleware(['signed', 'throttle:10,1']) ->where('token', '[0-9a-f-]{36}') ->name('api.uploads.store'); diff --git a/tests/Feature/Api/UploadControllerTest.php b/tests/Feature/Api/UploadControllerTest.php index 2d79584a..ac69900e 100644 --- a/tests/Feature/Api/UploadControllerTest.php +++ b/tests/Feature/Api/UploadControllerTest.php @@ -121,3 +121,19 @@ function signedUploadUrl(Workspace $ws, string $token, ?int $expiresInMinutes = $response->assertStatus(422); expect(Media::where('upload_token', $token)->exists())->toBeFalse(); }); + +test('rate limits floods from the same IP', function () { + for ($i = 0; $i < 10; $i++) { + $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image("f{$i}.png", 16, 16)], + ); + } + + $response = $this->postJson( + signedUploadUrl($this->workspace, (string) Str::uuid()), + ['media' => UploadedFile::fake()->image('over.png', 16, 16)], + ); + + $response->assertStatus(429); +}); From f5e8dd2730e612a69698e5861c0862024fa31ee0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 18 May 2026 20:03:52 -0300 Subject: [PATCH 21/65] fix(assets): always chunk uploads in GalleryBrowser The /assets page (and the picker dialog that reuses GalleryBrowser) posted files in a single multipart request, so anything past nginx / php-fpm's body limit failed with 413. The chunked endpoint and util already existed but were only wired into an orphan composable. - GalleryBrowser now always uploads via uploadChunked - Drop the orphan useMediaManager composable - Drop shouldUseChunkedUpload (no longer referenced) --- .../js/components/assets/GalleryBrowser.vue | 11 +- resources/js/composables/useMediaManager.ts | 193 ------------------ resources/js/utils/chunkedUpload.ts | 6 - 3 files changed, 7 insertions(+), 203 deletions(-) delete mode 100644 resources/js/composables/useMediaManager.ts diff --git a/resources/js/components/assets/GalleryBrowser.vue b/resources/js/components/assets/GalleryBrowser.vue index 1c0e9702..6095ac50 100644 --- a/resources/js/components/assets/GalleryBrowser.vue +++ b/resources/js/components/assets/GalleryBrowser.vue @@ -14,10 +14,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import debounce from '@/debounce'; -import { destroy as assetsDestroy, search as assetsSearch, store as assetsStore, storeFromUrl } from '@/routes/app/assets'; +import { destroy as assetsDestroy, search as assetsSearch, storeChunked as assetsStoreChunked, storeFromUrl } from '@/routes/app/assets'; import { search as giphySearch, trending as giphyTrending } from '@/routes/app/assets/giphy'; import { search as unsplashSearch, trending as unsplashTrending } from '@/routes/app/assets/unsplash'; import { store as storePost } from '@/routes/app/posts'; +import { uploadChunked } from '@/utils/chunkedUpload'; interface AssetMedia { id: string; @@ -152,7 +153,6 @@ const fileInput = ref(null); const uploadsSentinel = useTemplateRef('uploadsSentinel'); const isDragging = ref(false); const uploading = ref(false); -const httpUpload = useHttp<{ media: File | null }>({ media: null }); let uploadsObserver: IntersectionObserver | null = null; const fetchUploads = async (page: number, term: string) => { @@ -236,8 +236,11 @@ const uploadFiles = async (files: File[]) => { uploading.value = true; for (const file of files) { try { - httpUpload.media = file; - await httpUpload.post(assetsStore.url()); + await uploadChunked({ + file, + url: assetsStoreChunked.url(), + collection: 'assets', + }); } catch { // ignore individual failure } diff --git a/resources/js/composables/useMediaManager.ts b/resources/js/composables/useMediaManager.ts deleted file mode 100644 index bf2ed106..00000000 --- a/resources/js/composables/useMediaManager.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { ref, type Ref } from 'vue'; - -import { getMediaRulesForContentType } from '@/composables/useMediaRules'; -import { store as storeAsset, storeChunked as storeAssetChunked } from '@/routes/app/assets'; -import { uploadChunked, shouldUseChunkedUpload } from '@/utils/chunkedUpload'; - -export interface MediaItem { - id: string; - path: string; - url: string; - type?: string; - mime_type?: string; - original_filename?: string; -} - -interface PostPlatform { - id: string; - platform: string; - content_type: string | null; - media: MediaItem[]; -} - -interface UseMediaManagerOptions { - synced: Ref; - selectedPlatformIds: Ref; - platformContentTypes: Ref>; - postPlatforms: Ref; -} - -export const useMediaManager = (options: UseMediaManagerOptions) => { - const { synced, selectedPlatformIds, platformContentTypes, postPlatforms } = options; - - const platformMedia = ref>( - Object.fromEntries(postPlatforms.value.map((pp) => [pp.id, pp.media || []])), - ); - const isUploading = ref>({}); - - const isSingleMediaContentType = (platformId: string): boolean => { - const platform = postPlatforms.value.find((pp) => pp.id === platformId); - if (!platform) return false; - - const contentType = platformContentTypes.value[platformId] || platform.content_type; - if (!contentType) return false; - - const rules = getMediaRulesForContentType(contentType); - return rules.maxFiles === 1; - }; - - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ''; - - const uploadToAssets = async (file: File): Promise => { - try { - if (shouldUseChunkedUpload(file)) { - const data = await uploadChunked({ - file, - url: storeAssetChunked.url(), - model: 'workspace', - modelId: '', - collection: 'assets', - }); - return { - id: data.id, - path: data.path ?? '', - url: data.url, - type: data.type, - mime_type: data.mime_type, - original_filename: data.original_filename, - }; - } - - const formData = new FormData(); - formData.append('media', file); - - const response = await fetch(storeAsset.url(), { - method: 'POST', - headers: { - Accept: 'application/json', - 'X-CSRF-TOKEN': csrfToken, - 'X-Requested-With': 'XMLHttpRequest', - }, - body: formData, - }); - - if (!response.ok) return null; - - const data = await response.json(); - return { - id: data.id, - path: data.path ?? '', - url: data.url, - type: data.type, - mime_type: data.mime_type, - original_filename: data.original_filename, - }; - } catch { - return null; - } - }; - - const upload = async (files: File[], postPlatformId: string) => { - if (!files || files.length === 0) return; - - const platform = postPlatforms.value.find((pp) => pp.id === postPlatformId); - const contentType = platformContentTypes.value[postPlatformId] || platform?.content_type || ''; - const rules = getMediaRulesForContentType(contentType); - - const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId]; - - for (const id of targetPlatformIds) { - isUploading.value[id] = true; - } - - if (rules.maxFiles === 1) { - platformMedia.value[postPlatformId] = []; - } - - const currentMedia = platformMedia.value[postPlatformId] || []; - const remainingSlots = rules.maxFiles - currentMedia.length; - const filesToUpload = Array.from(files).slice(0, remainingSlots); - - if (filesToUpload.length === 0) { - for (const id of targetPlatformIds) { - isUploading.value[id] = false; - } - return; - } - - for (const file of filesToUpload) { - const mediaItem = await uploadToAssets(file); - if (!mediaItem) continue; - - for (const targetId of targetPlatformIds) { - if (isSingleMediaContentType(targetId)) { - platformMedia.value[targetId] = [mediaItem]; - } else { - platformMedia.value[targetId] = [...(platformMedia.value[targetId] || []), mediaItem]; - } - } - } - - for (const id of targetPlatformIds) { - isUploading.value[id] = false; - } - }; - - const remove = (postPlatformId: string, mediaId: string) => { - const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId]; - - for (const targetId of targetPlatformIds) { - platformMedia.value[targetId] = (platformMedia.value[targetId] || []).filter((m) => m.id !== mediaId); - } - }; - - const reorder = (postPlatformId: string, mediaIds: string[]) => { - const currentMedia = platformMedia.value[postPlatformId] || []; - const reorderedMedia = mediaIds - .map((id) => currentMedia.find((m) => m.id === id)) - .filter(Boolean) as MediaItem[]; - - const targetPlatformIds = synced.value ? selectedPlatformIds.value : [postPlatformId]; - - for (const targetId of targetPlatformIds) { - if (isSingleMediaContentType(targetId)) { - platformMedia.value[targetId] = reorderedMedia.length > 0 ? [reorderedMedia[0]] : []; - } else { - const targetMedia = platformMedia.value[targetId] || []; - const reorderedIds = reorderedMedia.map((m) => m.id); - platformMedia.value[targetId] = reorderedIds - .map((id) => targetMedia.find((m) => m.id === id) || reorderedMedia.find((m) => m.id === id)) - .filter(Boolean) as MediaItem[]; - } - } - }; - - const getMedia = (platformId: string): MediaItem[] => { - return platformMedia.value[platformId] || []; - }; - - const isUploadingFor = (platformId: string): boolean => { - return isUploading.value[platformId] || false; - }; - - return { - platformMedia, - isUploading, - upload, - remove, - reorder, - getMedia, - isUploadingFor, - isSingleMediaContentType, - }; -}; diff --git a/resources/js/utils/chunkedUpload.ts b/resources/js/utils/chunkedUpload.ts index 1ada0f15..0114cef2 100644 --- a/resources/js/utils/chunkedUpload.ts +++ b/resources/js/utils/chunkedUpload.ts @@ -85,9 +85,3 @@ export const uploadChunked = async (options: ChunkedUploadOptions): Promise { - return file.size > CHUNKED_UPLOAD_THRESHOLD; -}; From f7566c34eadc037bc2b1f6834625b4e477781dcc Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 08:19:40 +0200 Subject: [PATCH 22/65] Change media column to nullable with default null --- database/migrations/2026_01_14_232321_create_posts_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2026_01_14_232321_create_posts_table.php b/database/migrations/2026_01_14_232321_create_posts_table.php index a4411363..bf9593a9 100644 --- a/database/migrations/2026_01_14_232321_create_posts_table.php +++ b/database/migrations/2026_01_14_232321_create_posts_table.php @@ -18,7 +18,7 @@ public function up(): void $table->uuid('workspace_id'); $table->uuid('user_id')->nullable(); $table->text('content')->nullable(); - $table->json('media')->default('[]'); + $table->json('media')->nullable()->default(null); $table->string('status')->default('draft'); $table->timestamp('scheduled_at')->nullable(); $table->timestamp('published_at')->nullable(); From 15cbe9616fbf10b1f190bc96df86c987454d7897 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 08:28:07 +0200 Subject: [PATCH 23/65] Change reactions column to nullable with default null --- .../migrations/2026_04_15_000001_create_post_comments_table.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migrations/2026_04_15_000001_create_post_comments_table.php b/database/migrations/2026_04_15_000001_create_post_comments_table.php index ee7769e7..ef1e40b2 100644 --- a/database/migrations/2026_04_15_000001_create_post_comments_table.php +++ b/database/migrations/2026_04_15_000001_create_post_comments_table.php @@ -16,7 +16,7 @@ public function up(): void $table->uuid('user_id')->nullable(); $table->uuid('parent_id')->nullable(); $table->text('body'); - $table->json('reactions')->default('[]'); + $table->json('reactions')->nullable()->default(null); $table->timestamps(); $table->foreign('post_id')->references('id')->on('posts')->cascadeOnDelete(); From 78bee705ff70d23372a44929e7fc796657a72ece Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 09:30:49 +0200 Subject: [PATCH 24/65] Change storage to public disk for media uploads --- app/Models/Traits/HasMedia.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index c1b9cb5e..7fbb8590 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -88,7 +88,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr $filename = Str::uuid().'.'.$normalizedExt; $path = 'medias/'.$filename; - Storage::put($path, $normalizedBytes); + Storage::disk('public')->put($path, $normalizedBytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), @@ -126,7 +126,7 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str $filename = Str::uuid().'.'.$normalizedExt; $storagePath = 'medias/'.$filename; - Storage::put($storagePath, $normalizedBytes); + Storage::disk('public')->put($storagePath, $normalizedBytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), From 3018c0353f76ab59545f77f31f2b9d3ae8ae3845 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 09:31:40 +0200 Subject: [PATCH 25/65] Change storage method to use public disk for assets --- app/Http/Controllers/App/AssetController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index eab0588d..0a89a4fb 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -145,7 +145,7 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $filename = Str::uuid().'.'.$extension; $path = 'medias/'.$filename; - Storage::put($path, $response->body()); + Storage::disk('public')->put($path, $response->body()); $meta = []; $tempFile = tempnam(sys_get_temp_dir(), 'unsplash'); From 6f96d67dbcc089a27918a0f7102ffb160b94c0d0 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 08:16:43 -0300 Subject: [PATCH 26/65] fix(social): distinguish platform-down from token-expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a provider's API was down (5xx, timeout, DNS), the hourly RefreshSocialToken job and daily VerifyWorkspaceConnections job were treating it as "token revoked" and emailing the user to reconnect. Bluesky going offline triggered false-positive disconnect notifications because Bluesky access tokens are short-lived (2h) so every hourly refresh failed during the outage. - New PlatformUnavailableException: API unreachable / 5xx, transient. TokenExpiredException stays for 4xx (token is provably bad). - New TokenRefreshClient: normalizes failure semantics for OAuth refresh HTTP calls across all providers. Takes a Platform enum so typos fail at compile time and the user-facing label comes from one source. - ConnectionVerifier: all 8 refresh*Token methods route through the new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's default PDS host moved into config/trypost.php alongside the existing per-platform entries. - RefreshSocialToken job: PlatformUnavailableException → log warning and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next scheduled tick retries. - VerifyWorkspaceConnections job: PlatformUnavailableException from the inner refresh propagates and is treated as a transient skip. --- .../PlatformUnavailableException.php | 23 +++ app/Jobs/RefreshSocialToken.php | 11 ++ app/Jobs/VerifyWorkspaceConnections.php | 11 ++ app/Services/Social/ConnectionVerifier.php | 166 ++++++------------ app/Services/Social/TokenRefreshClient.php | 87 +++++++++ config/trypost.php | 5 + tests/Feature/Jobs/RefreshSocialTokenTest.php | 23 +++ .../Social/ConnectionVerifierTest.php | 70 ++++++++ .../VerifyWorkspaceConnectionsTest.php | 21 +++ 9 files changed, 309 insertions(+), 108 deletions(-) create mode 100644 app/Exceptions/PlatformUnavailableException.php create mode 100644 app/Services/Social/TokenRefreshClient.php diff --git a/app/Exceptions/PlatformUnavailableException.php b/app/Exceptions/PlatformUnavailableException.php new file mode 100644 index 00000000..27c10e4b --- /dev/null +++ b/app/Exceptions/PlatformUnavailableException.php @@ -0,0 +1,23 @@ +refreshToken($this->account); + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). Leave the account alone — + // next scheduled run will try again. Critically, do NOT mark + // the account expired: that would trigger a false-positive + // "reconnect your account" notification. + Log::warning('Token refresh skipped: platform unavailable', [ + 'account_id' => $this->account->id, + 'platform' => $this->account->platform->value, + 'error' => $e->getMessage(), + ]); } catch (TokenExpiredException $e) { // refresh_token rejected by the provider (revoked / rotated / // expired beyond refresh). Mark the account so the user is diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index d635ed97..5174b13d 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -7,6 +7,7 @@ use App\Enums\Notification\Channel; use App\Enums\Notification\Type; use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Mail\WorkspaceConnectionsDisconnected; use App\Models\SocialAccount; @@ -63,6 +64,16 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco try { $verifier->verify($account); + return true; + } catch (PlatformUnavailableException $e) { + // Platform is down (5xx / network). The account's token is not + // provably bad — skip silently and try again next pass. + Log::warning('Social account verification skipped: platform unavailable', [ + 'account_id' => $account->id, + 'platform' => $account->platform->value, + 'error' => $e->getMessage(), + ]); + return true; } catch (TokenExpiredException $e) { Log::warning('Social account connection is invalid', [ diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index d65a493d..e5c9fc17 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -5,11 +5,11 @@ namespace App\Services\Social; use App\Enums\SocialAccount\Platform; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; class ConnectionVerifier { @@ -17,6 +17,7 @@ class ConnectionVerifier * Verify that a social account connection is still valid. * * @throws TokenExpiredException if the connection is invalid + * @throws PlatformUnavailableException if the platform's API is down */ public function verify(SocialAccount $account): bool { @@ -27,6 +28,9 @@ public function verify(SocialAccount $account): bool // LinkedIn, etc.) invalidate the previous refresh_token on each // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. + // + // PlatformUnavailableException from refresh propagates naturally so + // the caller can distinguish "platform is down" from "token bad". if ($account->is_token_expired) { $this->refreshToken($account); @@ -113,17 +117,13 @@ private function refreshLinkedInToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh LinkedIn token'); - } + $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin.client_id'), + 'client_secret' => config('services.linkedin.client_secret'), + ])); $data = $response->json(); @@ -145,17 +145,12 @@ private function refreshXToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->post(config('trypost.platforms.x.api').'/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh X token'); - } + ])); $data = $response->json(); @@ -170,13 +165,16 @@ private function refreshXToken(SocialAccount $account): void private function refreshBlueskyToken(SocialAccount $account): void { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); + $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh token first - $response = Http::withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + // Try refresh token first. Connection errors / 5xx surface as + // PlatformUnavailableException (Bluesky is down — don't touch the + // account's status). 4xx falls through to the re-auth fallback. + try { + $response = $client->send(fn () => Http::withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - if ($response->successful()) { $data = $response->json(); $account->update([ 'access_token' => data_get($data, 'accessJwt'), @@ -187,35 +185,29 @@ private function refreshBlueskyToken(SocialAccount $account): void $account->refresh(); return; + } catch (TokenExpiredException) { + // refresh token was rejected (4xx) — fall back to re-auth below } - // If refresh fails, re-authenticate with stored credentials if (isset($account->meta['password'])) { try { - $password = decrypt($account->meta['password']); - $identifier = $account->meta['identifier']; + $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $account->meta['identifier'], + 'password' => decrypt($account->meta['password']), + ])); - $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $identifier, - 'password' => $password, + $data = $reauth->json(); + $account->update([ + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), + 'token_expires_at' => now()->addHours(2), ]); - if ($response->successful()) { - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); + $account->refresh(); - $account->refresh(); - - return; - } - } catch (\Exception $e) { - Log::error('ConnectionVerifier: Bluesky re-authentication failed', [ - 'error' => $e->getMessage(), - ]); + return; + } catch (TokenExpiredException) { + // re-auth rejected with stored credentials — fall through } } @@ -228,17 +220,13 @@ private function refreshYouTubeToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + ])); $data = $response->json(); @@ -256,17 +244,13 @@ private function refreshTikTokToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh TikTok token'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + ])); $data = $response->json(); @@ -287,18 +271,13 @@ private function refreshPinterestToken(SocialAccount $account): void $credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret')); - $response = Http::withHeaders([ + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([ 'Authorization' => "Basic {$credentials}", 'Content-Type' => 'application/x-www-form-urlencoded', ])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Pinterest token'); - } + ])); $data = $response->json(); @@ -314,15 +293,10 @@ private function refreshPinterestToken(SocialAccount $account): void private function refreshThreadsToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Threads token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -338,15 +312,10 @@ private function refreshThreadsToken(SocialAccount $account): void private function refreshInstagramToken(SocialAccount $account): void { - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]); - throw new TokenExpiredException('Failed to refresh Instagram token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); @@ -524,7 +493,7 @@ private function verifyPinterest(SocialAccount $account): bool private function verifyBluesky(SocialAccount $account): bool { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); $response = Http::withToken($account->access_token) ->get("{$service}/xrpc/app.bsky.actor.getProfile", [ @@ -554,23 +523,4 @@ private function verifyMastodon(SocialAccount $account): bool return $response->successful(); } - - private function redactBody(string $body): string - { - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - '/"token"\s*:\s*"([^"]+)"/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - '"token":"[REDACTED]"', - ], - $body - ); - } } diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php new file mode 100644 index 00000000..e1959897 --- /dev/null +++ b/app/Services/Social/TokenRefreshClient.php @@ -0,0 +1,87 @@ +platform->label(); + + try { + $response = $request(); + } catch (ConnectionException $e) { + throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}"); + } + + if ($response->serverError()) { + throw new PlatformUnavailableException( + "{$name} API returned {$response->status()} during token refresh", + $response->status(), + ); + } + + if ($response->failed()) { + Log::error("TokenRefreshClient: {$name} token refresh failed", [ + 'body' => $this->redactBody($response->body()), + ]); + throw new TokenExpiredException("Failed to refresh {$name} token"); + } + + return $response; + } + + private function redactBody(string $body): string + { + return preg_replace( + [ + '/access_token=([^&"\s]+)/', + '/"access_token"\s*:\s*"([^"]+)"/', + '/Bearer\s+\S+/', + '/"token"\s*:\s*"([^"]+)"/', + ], + [ + 'access_token=[REDACTED]', + '"access_token":"[REDACTED]"', + 'Bearer [REDACTED]', + '"token":"[REDACTED]"', + ], + $body + ); + } +} diff --git a/config/trypost.php b/config/trypost.php index 9ab2e2b9..29fcfb95 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -63,6 +63,8 @@ 'linkedin' => [ 'enabled' => env('LINKEDIN_ENABLED', true), 'api' => env('LINKEDIN_API', 'https://api.linkedin.com'), + // OAuth host is different from the data API (api.linkedin.com). + 'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'), ], 'linkedin-page' => [ 'enabled' => env('LINKEDIN_PAGE_ENABLED', true), @@ -80,6 +82,7 @@ 'enabled' => env('YOUTUBE_ENABLED', true), 'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'), 'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'), + 'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'), ], 'facebook' => [ 'enabled' => env('FACEBOOK_ENABLED', true), @@ -108,6 +111,8 @@ 'bluesky' => [ 'enabled' => env('BLUESKY_ENABLED', true), 'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'), + // Default PDS used when the account has no `meta.service` override. + 'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'), ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 41b956c1..172076a7 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\RefreshSocialToken; use App\Jobs\SendNotification; @@ -67,3 +68,25 @@ expect($this->account->fresh()->status)->toBe(Status::Connected); }); + +test('refresh job does NOT mark account expired when platform is unavailable', function () { + Queue::fake(); + + Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) { + return $message === 'Token refresh skipped: platform unavailable' + && $context['account_id'] === $this->account->id + && str_contains($context['error'], '503'); + }); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('refreshToken')->once()->andThrow( + new PlatformUnavailableException('X API returned 503 during token refresh', 503) + ); + app()->instance(ConnectionVerifier::class, $verifier); + + (new RefreshSocialToken($this->account))->handle($verifier); + + // Critically: account status stays Connected, no notification dispatched. + expect($this->account->fresh()->status)->toBe(Status::Connected); + Queue::assertNotPushed(SendNotification::class); +}); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index cbadf68d..9d025385 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -2,9 +2,11 @@ declare(strict_types=1); +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\ConnectionVerifier; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; test('verifies account without refresh when token is not expired', function () { @@ -360,3 +362,71 @@ expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class); }); + +test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () { + Http::fake([ + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => ['service' => 'https://bsky.social'], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('connection failure during refresh raises PlatformUnavailableException', function () { + Http::fake([ + 'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), + ]); + + $account = SocialAccount::factory()->youtube()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + +test('4xx during refresh keeps raising TokenExpiredException', function () { + Http::fake([ + 'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class); +}); + +test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { + Http::fake([ + // Both endpoints return 5xx — the platform is genuinely down. + 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + 'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503), + ]); + + $account = SocialAccount::factory()->bluesky()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + 'meta' => [ + 'service' => 'https://bsky.social', + 'identifier' => 'user.bsky.social', + 'password' => encrypt('app-password'), + ], + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); diff --git a/tests/Feature/VerifyWorkspaceConnectionsTest.php b/tests/Feature/VerifyWorkspaceConnectionsTest.php index 2f351c78..5eaff679 100644 --- a/tests/Feature/VerifyWorkspaceConnectionsTest.php +++ b/tests/Feature/VerifyWorkspaceConnectionsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Enums\SocialAccount\Status; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\VerifyWorkspaceConnections; use App\Mail\WorkspaceConnectionsDisconnected; @@ -121,6 +122,26 @@ }); }); +test('job does NOT disconnect or email when platform is unavailable', function () { + Mail::fake(); + + $workspace = Workspace::factory()->create(); + $account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]); + + $verifier = mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->andThrow( + new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503) + ); + + app()->instance(ConnectionVerifier::class, $verifier); + + VerifyWorkspaceConnections::dispatch($workspace); + + // Status untouched, no notification email. + expect($account->fresh()->status)->toBe(Status::Connected); + Mail::assertNothingQueued(); +}); + test('job skips already disconnected accounts', function () { Mail::fake(); From d5e28e3d02069fa990a8f8cf4aa682c1a6e5afe3 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 08:27:46 -0300 Subject: [PATCH 27/65] fix(social): move Mastodon default instance to config + cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups: - verifyMastodon was the last hardcoded host left after the PR moved LinkedIn/YouTube/Bluesky to config. Adds trypost.platforms.mastodon .default_instance (env MASTODON_DEFAULT_INSTANCE) and reads from it. - refreshToken() docblock now declares @throws PlatformUnavailableException (the whole point of the PR was missing from its contract). - Strip the new explanatory comments inside catch blocks and tests — rationale lives in the commit / PR, not inline. The two comments inside empty `catch (TokenExpiredException) {}` blocks stay because there the comment is the only thing telling the reader why the exception is swallowed. --- app/Jobs/RefreshSocialToken.php | 8 -------- app/Jobs/VerifyWorkspaceConnections.php | 2 -- app/Services/Social/ConnectionVerifier.php | 13 ++++--------- config/trypost.php | 2 ++ tests/Feature/Jobs/RefreshSocialTokenTest.php | 1 - .../Services/Social/ConnectionVerifierTest.php | 1 - tests/Feature/VerifyWorkspaceConnectionsTest.php | 1 - 7 files changed, 6 insertions(+), 22 deletions(-) diff --git a/app/Jobs/RefreshSocialToken.php b/app/Jobs/RefreshSocialToken.php index 86957f2d..25b6b1bf 100644 --- a/app/Jobs/RefreshSocialToken.php +++ b/app/Jobs/RefreshSocialToken.php @@ -26,20 +26,12 @@ public function handle(ConnectionVerifier $verifier): void try { $verifier->refreshToken($this->account); } catch (PlatformUnavailableException $e) { - // Platform is down (5xx / network). Leave the account alone — - // next scheduled run will try again. Critically, do NOT mark - // the account expired: that would trigger a false-positive - // "reconnect your account" notification. Log::warning('Token refresh skipped: platform unavailable', [ 'account_id' => $this->account->id, 'platform' => $this->account->platform->value, 'error' => $e->getMessage(), ]); } catch (TokenExpiredException $e) { - // refresh_token rejected by the provider (revoked / rotated / - // expired beyond refresh). Mark the account so the user is - // notified immediately instead of waiting for the next failed - // publish or the daily verify pass. $this->account->markAsTokenExpired($e->getMessage()); } catch (Throwable $e) { Log::warning('Proactive token refresh failed', [ diff --git a/app/Jobs/VerifyWorkspaceConnections.php b/app/Jobs/VerifyWorkspaceConnections.php index 5174b13d..6d01c957 100644 --- a/app/Jobs/VerifyWorkspaceConnections.php +++ b/app/Jobs/VerifyWorkspaceConnections.php @@ -66,8 +66,6 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco return true; } catch (PlatformUnavailableException $e) { - // Platform is down (5xx / network). The account's token is not - // provably bad — skip silently and try again next pass. Log::warning('Social account verification skipped: platform unavailable', [ 'account_id' => $account->id, 'platform' => $account->platform->value, diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index e5c9fc17..d13bb620 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -28,9 +28,6 @@ public function verify(SocialAccount $account): bool // LinkedIn, etc.) invalidate the previous refresh_token on each // refresh, so proactive refreshes during races cause false-positive // disconnects even though the access_token still works fine. - // - // PlatformUnavailableException from refresh propagates naturally so - // the caller can distinguish "platform is down" from "token bad". if ($account->is_token_expired) { $this->refreshToken($account); @@ -76,9 +73,10 @@ private function callVerifyEndpoint(SocialAccount $account): bool * Refresh the account's token via the platform-specific OAuth flow. * Callers that want the smart "try access_token first" behavior should * use verify() instead. This method always attempts a refresh under - * the per-account lock and throws TokenExpiredException on failure. + * the per-account lock. * - * @throws TokenExpiredException if refresh fails + * @throws TokenExpiredException if refresh is rejected by the provider (4xx) + * @throws PlatformUnavailableException if the platform is unreachable (5xx / network) */ public function refreshToken(SocialAccount $account): void { @@ -168,9 +166,6 @@ private function refreshBlueskyToken(SocialAccount $account): void $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh token first. Connection errors / 5xx surface as - // PlatformUnavailableException (Bluesky is down — don't touch the - // account's status). 4xx falls through to the re-auth fallback. try { $response = $client->send(fn () => Http::withToken($account->refresh_token) ->post("{$service}/xrpc/com.atproto.server.refreshSession")); @@ -512,7 +507,7 @@ private function verifyBluesky(SocialAccount $account): bool private function verifyMastodon(SocialAccount $account): bool { - $instance = $account->meta['instance'] ?? 'https://mastodon.social'; + $instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance'); $response = Http::withToken($account->access_token) ->get("{$instance}/api/v1/accounts/verify_credentials"); diff --git a/config/trypost.php b/config/trypost.php index 29fcfb95..45f1d93d 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -116,6 +116,8 @@ ], 'mastodon' => [ 'enabled' => env('MASTODON_ENABLED', true), + // Default instance used when the account has no `meta.instance` override. + 'default_instance' => env('MASTODON_DEFAULT_INSTANCE', 'https://mastodon.social'), ], ], diff --git a/tests/Feature/Jobs/RefreshSocialTokenTest.php b/tests/Feature/Jobs/RefreshSocialTokenTest.php index 172076a7..c9a9226d 100644 --- a/tests/Feature/Jobs/RefreshSocialTokenTest.php +++ b/tests/Feature/Jobs/RefreshSocialTokenTest.php @@ -86,7 +86,6 @@ (new RefreshSocialToken($this->account))->handle($verifier); - // Critically: account status stays Connected, no notification dispatched. expect($this->account->fresh()->status)->toBe(Status::Connected); Queue::assertNotPushed(SendNotification::class); }); diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 9d025385..8488a972 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -411,7 +411,6 @@ test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { Http::fake([ - // Both endpoints return 5xx — the platform is genuinely down. 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), 'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503), ]); diff --git a/tests/Feature/VerifyWorkspaceConnectionsTest.php b/tests/Feature/VerifyWorkspaceConnectionsTest.php index 5eaff679..19976701 100644 --- a/tests/Feature/VerifyWorkspaceConnectionsTest.php +++ b/tests/Feature/VerifyWorkspaceConnectionsTest.php @@ -137,7 +137,6 @@ VerifyWorkspaceConnections::dispatch($workspace); - // Status untouched, no notification email. expect($account->fresh()->status)->toBe(Status::Connected); Mail::assertNothingQueued(); }); From 166008422788b23ec2a4820cf9986f1520bc95fa Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 08:34:35 -0300 Subject: [PATCH 28/65] refactor(social): use config for OAuth hosts everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier in the PR the new configs (linkedin.oauth_api, youtube.oauth_api, bluesky.default_service, mastodon.default_instance) were only read by ConnectionVerifier. The same URLs were still hardcoded in the publishers, analytics and the Bluesky auth controller — meaning a self-hosted user setting BLUESKY_DEFAULT_SERVICE or MASTODON_DEFAULT_INSTANCE in env would get split behavior: refresh/verify honor the override, publish/analytics don't. Routes all 10 remaining call sites through the same config values so the overrides actually work end-to-end. --- app/Http/Controllers/Auth/BlueskyController.php | 2 +- app/Services/Social/BlueskyPublisher.php | 4 ++-- app/Services/Social/LinkedInPageAnalytics.php | 2 +- app/Services/Social/LinkedInPagePublisher.php | 2 +- app/Services/Social/LinkedInPublisher.php | 2 +- app/Services/Social/MastodonAnalytics.php | 2 +- app/Services/Social/MastodonPublisher.php | 2 +- app/Services/Social/YouTubeAnalytics.php | 2 +- app/Services/Social/YouTubePublisher.php | 2 +- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Auth/BlueskyController.php b/app/Http/Controllers/Auth/BlueskyController.php index 8ec10b91..4a74d99a 100644 --- a/app/Http/Controllers/Auth/BlueskyController.php +++ b/app/Http/Controllers/Auth/BlueskyController.php @@ -53,7 +53,7 @@ public function store(Request $request): View|RedirectResponse $this->authorize('manageAccounts', $workspace); - $service = 'https://bsky.social'; + $service = config('trypost.platforms.bluesky.default_service'); try { // Authenticate with Bluesky diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index f545e2df..ec18db80 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -26,7 +26,7 @@ public function publish(PostPlatform $postPlatform): array $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; $account = $postPlatform->socialAccount; - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Refresh token if needed if ($account->is_token_expired || $account->is_token_expiring_soon) { @@ -270,7 +270,7 @@ private function buildPostUrl(string $handle, string $postId): string public function refreshToken(SocialAccount $account): void { - $service = $account->meta['service'] ?? 'https://bsky.social'; + $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); // Try refresh first $response = $this->socialHttp()->withToken($account->refresh_token) diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 8c809b9a..1c2c5b6a 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -240,7 +240,7 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ + $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, 'client_id' => config('services.linkedin-openid.client_id'), diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 7c7ed796..d4a3c691 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -458,7 +458,7 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ + $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, 'client_id' => config('services.linkedin-openid.client_id'), diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 37bdd435..540e37ae 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -439,7 +439,7 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [ + $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, 'client_id' => config('services.linkedin.client_id'), diff --git a/app/Services/Social/MastodonAnalytics.php b/app/Services/Social/MastodonAnalytics.php index e071fbeb..606ab8a0 100644 --- a/app/Services/Social/MastodonAnalytics.php +++ b/app/Services/Social/MastodonAnalytics.php @@ -20,7 +20,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'missing_post_id']; } - $instance = data_get($account->meta, 'instance', 'https://mastodon.social'); + $instance = data_get($account->meta, 'instance', config('trypost.platforms.mastodon.default_instance')); // Public posts: no auth needed. Our token only requests write scopes // (read:accounts + write:statuses + write:media), so attaching the diff --git a/app/Services/Social/MastodonPublisher.php b/app/Services/Social/MastodonPublisher.php index 66ac4b49..218d099d 100644 --- a/app/Services/Social/MastodonPublisher.php +++ b/app/Services/Social/MastodonPublisher.php @@ -25,7 +25,7 @@ public function publish(PostPlatform $postPlatform): array $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; $account = $postPlatform->socialAccount; - $instance = $account->meta['instance'] ?? 'https://mastodon.social'; + $instance = $account->meta['instance'] ?? config('trypost.platforms.mastodon.default_instance'); $medias = $postPlatform->post->mediaItems; $mediaIds = []; diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index 0d42997e..41f6a90c 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -168,7 +168,7 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ 'client_id' => config('services.google.client_id'), 'client_secret' => config('services.google.client_secret'), 'grant_type' => 'refresh_token', diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 7525baa7..1f67c6d0 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -229,7 +229,7 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ + $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ 'client_id' => config('services.google.client_id'), 'client_secret' => config('services.google.client_secret'), 'grant_type' => 'refresh_token', From 090cc761dd86f91cee6a01ef0871ae1228d3473a Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 08:42:02 -0300 Subject: [PATCH 29/65] test(social): smoke tests for analytics refactored in this PR LinkedInPageAnalytics and MastodonAnalytics were the only two of the 11 files refactored to read OAuth host / default instance from config that had zero test coverage. Adds smoke tests that assert the HTTP request hits the configured URL, so a typo in the config key (e.g. linkedin.api vs linkedin.oauth_api) would now fail loudly. --- .../Social/LinkedInPageAnalyticsTest.php | 55 ++++++++++++++ .../Services/Social/MastodonAnalyticsTest.php | 75 +++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php create mode 100644 tests/Feature/Services/Social/MastodonAnalyticsTest.php diff --git a/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php new file mode 100644 index 00000000..0836940d --- /dev/null +++ b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php @@ -0,0 +1,55 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->socialAccount = SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $this->workspace->id, + 'token_expires_at' => now()->subHour(), + 'refresh_token' => 'old_refresh_token', + ]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); + $this->postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $this->socialAccount->id, + 'platform' => Platform::LinkedInPage, + 'content_type' => ContentType::LinkedInPagePost, + 'platform_post_id' => 'urn:li:share:1234567890', + ]); +}); + +test('linkedin page analytics refresh hits the configured oauth host', function () { + Http::fake([ + 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'access_token' => 'new_token', + 'refresh_token' => 'new_refresh_token', + 'expires_in' => 5184000, + ], 200), + 'api.linkedin.com/rest/socialActions/*' => Http::response([ + 'likesSummary' => ['totalLikes' => 0], + 'commentsSummary' => ['aggregatedTotalComments' => 0], + ], 200), + ]); + + (new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform); + + Http::assertSent(fn ($request) => str_contains( + $request->url(), + rtrim((string) config('trypost.platforms.linkedin.oauth_api'), '/').'/oauth/v2/accessToken' + )); +}); diff --git a/tests/Feature/Services/Social/MastodonAnalyticsTest.php b/tests/Feature/Services/Social/MastodonAnalyticsTest.php new file mode 100644 index 00000000..0a6fe7cc --- /dev/null +++ b/tests/Feature/Services/Social/MastodonAnalyticsTest.php @@ -0,0 +1,75 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + ]); +}); + +test('mastodon analytics falls back to configured default instance', function () { + $defaultInstance = (string) config('trypost.platforms.mastodon.default_instance'); + + Http::fake([ + "{$defaultInstance}/api/v1/statuses/*" => Http::response([ + 'favourites_count' => 5, + 'reblogs_count' => 2, + 'replies_count' => 1, + ], 200), + ]); + + $account = SocialAccount::factory()->mastodon()->create([ + 'workspace_id' => $this->workspace->id, + 'meta' => [], + ]); + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => Platform::Mastodon, + 'content_type' => ContentType::MastodonPost, + 'platform_post_id' => '109876543210', + ]); + + $metrics = (new MastodonAnalytics)->fetchPostMetrics($postPlatform); + + expect($metrics)->toBeArray(); + Http::assertSent(fn ($request) => str_starts_with($request->url(), $defaultInstance.'/api/v1/statuses/')); +}); + +test('mastodon analytics honors per-account instance override', function () { + Http::fake([ + 'techhub.social/api/v1/statuses/*' => Http::response([ + 'favourites_count' => 0, 'reblogs_count' => 0, 'replies_count' => 0, + ], 200), + ]); + + $account = SocialAccount::factory()->mastodon()->create([ + 'workspace_id' => $this->workspace->id, + 'meta' => ['instance' => 'https://techhub.social'], + ]); + $postPlatform = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => Platform::Mastodon, + 'content_type' => ContentType::MastodonPost, + 'platform_post_id' => '999', + ]); + + (new MastodonAnalytics)->fetchPostMetrics($postPlatform); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://techhub.social/api/v1/statuses/')); +}); From 32e1f89adb43dfd1c18278a506de1a797db4f592 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:01:02 -0300 Subject: [PATCH 30/65] fix(social): publish flow honors PlatformUnavailable too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commits in this PR closed the loophole on the hourly / daily token-refresh jobs. The same loophole remained on the publish path: every per-platform publisher (LinkedIn, X, YouTube, TikTok, Threads, Instagram, Pinterest, Bluesky and their Analytics siblings) has its own refreshToken() called before publishing a scheduled post, and all of those treated any non-2xx as TokenExpired — including 5xx. Result before this commit: a Bluesky outage that coincided with a scheduled publish would mark the account as expired and fail the post. Changes: - Route every refreshToken() in the 16 publisher / analytics classes through TokenRefreshClient::for(Platform::X)->send(...). - TokenRefreshClient now also fills platformErrorCode from the HTTP status and pulls error_description / error.message from the JSON body, preserving the richer info LinkedIn / X / TikTok / Pinterest / Threads used to put on their TokenExpiredException. - PublishToSocialPlatform catches PlatformUnavailableException explicitly: the post is marked failed (category: platform_unavailable, with http_status in error_context) but the account stays Connected. No retry inside this job — the scheduler reattempts the next run. Test added: publish flow does NOT mark account expired when the publisher throws PlatformUnavailable. Full suite: 1569 passing. --- app/Jobs/PublishToSocialPlatform.php | 14 ++++++ app/Services/Social/BlueskyPublisher.php | 46 ++++++++----------- app/Services/Social/InstagramAnalytics.php | 9 +--- app/Services/Social/InstagramPublisher.php | 11 +---- app/Services/Social/LinkedInPageAnalytics.php | 19 ++++---- app/Services/Social/LinkedInPagePublisher.php | 20 +++----- app/Services/Social/LinkedInPublisher.php | 20 +++----- app/Services/Social/PinterestAnalytics.php | 10 ++-- app/Services/Social/PinterestPublisher.php | 12 +---- app/Services/Social/ThreadsAnalytics.php | 11 ++--- app/Services/Social/ThreadsPublisher.php | 13 ++---- app/Services/Social/TikTokAnalytics.php | 19 ++++---- app/Services/Social/TikTokCreatorInfo.php | 19 ++++---- app/Services/Social/TikTokPublisher.php | 21 ++++----- app/Services/Social/TokenRefreshClient.php | 8 +++- app/Services/Social/XAnalytics.php | 10 ++-- app/Services/Social/XPublisher.php | 11 +---- app/Services/Social/YouTubeAnalytics.php | 20 ++++---- app/Services/Social/YouTubePublisher.php | 20 ++++---- .../Jobs/PublishToSocialPlatformTest.php | 23 ++++++++++ 20 files changed, 144 insertions(+), 192 deletions(-) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index dd0128ea..0ee2b719 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -10,6 +10,7 @@ use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; use App\Events\PostPlatformStatusUpdated; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\SocialPublishException; use App\Exceptions\TokenExpiredException; use App\Mail\PostPublished; @@ -110,6 +111,19 @@ public function handle(): void $result = $publisher->publish($this->postPlatform); $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); break; + } catch (PlatformUnavailableException $e) { + Log::warning('Publish skipped: platform unavailable', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $e->getMessage(), + ]); + + $this->postPlatform->markAsFailed($e->getMessage(), [ + 'category' => 'platform_unavailable', + 'http_status' => $e->httpStatus, + 'failed_at' => now()->toIso8601String(), + ]); + break; } catch (TokenExpiredException $e) { if ($attempt < $maxAttempts) { try { diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index ec18db80..68c5fc15 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -271,12 +271,12 @@ private function buildPostUrl(string $handle, string $postId): string public function refreshToken(SocialAccount $account): void { $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); + $client = TokenRefreshClient::for(Platform::Bluesky); - // Try refresh first - $response = $this->socialHttp()->withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession"); + try { + $response = $client->send(fn () => $this->socialHttp()->withToken($account->refresh_token) + ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - if ($response->successful()) { $data = $response->json(); $account->update([ 'access_token' => data_get($data, 'accessJwt'), @@ -285,37 +285,27 @@ public function refreshToken(SocialAccount $account): void ]); return; + } catch (TokenExpiredException) { + // refresh token rejected (4xx) — fall back to re-auth below } - Log::warning('Bluesky refresh token failed, trying re-authentication', [ - 'status' => $response->status(), - ]); - - // If refresh fails, re-authenticate with stored credentials if (isset($account->meta['password'])) { try { - $password = decrypt($account->meta['password']); - $identifier = $account->meta['identifier']; + $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ + 'identifier' => $account->meta['identifier'], + 'password' => decrypt($account->meta['password']), + ])); - $response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $identifier, - 'password' => $password, + $data = $reauth->json(); + $account->update([ + 'access_token' => data_get($data, 'accessJwt'), + 'refresh_token' => data_get($data, 'refreshJwt'), + 'token_expires_at' => now()->addHours(2), ]); - if ($response->successful()) { - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - return; - } - } catch (\Exception $e) { - Log::error('Bluesky re-authentication failed', [ - 'error' => $e->getMessage(), - ]); + return; + } catch (TokenExpiredException) { + // re-auth rejected with stored credentials — fall through } } diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 3b98fa44..126a4626 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -211,15 +211,10 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for Instagram account'); } - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Instagram token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 85c8b0fb..9ad27dd5 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -8,7 +8,6 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -403,16 +402,10 @@ private function refreshToken(SocialAccount $account): void return; } - $response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ 'grant_type' => 'ig_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh Instagram token'); - } + ])); $data = $response->json(); $newToken = data_get($data, 'access_token'); diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 1c2c5b6a..98c1d519 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -240,17 +241,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ]); - - if ($response->failed()) { - Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('LinkedIn token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin-openid.client_id'), + 'client_secret' => config('services.linkedin-openid.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index d4a3c691..7486f5d3 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -458,19 +458,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin-openid.client_id'), + 'client_secret' => config('services.linkedin-openid.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 540e37ae..ed6d230c 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -439,19 +439,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for LinkedIn account'); } - $response = Http::asForm()->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + 'client_id' => config('services.linkedin.client_id'), + 'client_secret' => config('services.linkedin.client_secret'), + ])); $data = $response->json(); diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 78745fb6..2a343b9e 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -167,18 +168,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for Pinterest account'); } - $response = Http::withBasicAuth( + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withBasicAuth( config('services.pinterest.client_id'), config('services.pinterest.client_secret'), )->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Pinterest token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index ed6c4364..116b93f5 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -8,7 +8,6 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\PinterestPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -403,7 +402,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, private function refreshToken(SocialAccount $account): void { - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::asForm() ->withBasicAuth( config('services.pinterest.client_id'), config('services.pinterest.client_secret') @@ -411,14 +410,7 @@ private function refreshToken(SocialAccount $account): void ->post($this->baseUrl.'/oauth/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh Pinterest token'), - platformErrorCode: (string) $response->status(), - ); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index a34fd8f5..dc245f63 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -4,7 +4,7 @@ namespace App\Services\Social; -use App\Exceptions\TokenExpiredException; +use App\Enums\SocialAccount\Platform; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -141,15 +141,10 @@ private function getHttpClient(): PendingRequest private function refreshToken(SocialAccount $account): void { - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - Log::error('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('Threads token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index d799fa68..135f9dd4 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -4,8 +4,8 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ThreadsPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -306,17 +306,10 @@ private function waitForMediaProcessing(string $containerId, string $accessToken private function refreshToken(SocialAccount $account): void { // Threads uses long-lived tokens that can be refreshed - $response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ + $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ 'grant_type' => 'th_refresh_token', 'access_token' => $account->access_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error.message', 'Failed to refresh Threads token'), - platformErrorCode: (string) $response->status(), - ); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 7d30333d..7532ae3c 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -166,17 +167,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('TikTok token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 42a4858d..d3fe8ab5 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -130,17 +131,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('TikTok token refresh failed'); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 78e4d2d6..c0b875d0 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; use App\Exceptions\TokenExpiredException; @@ -322,19 +323,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for TikTok account'); } - $response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'), - platformErrorCode: (string) $response->status(), - ); - } + $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ + 'client_key' => config('services.tiktok.client_id'), + 'client_secret' => config('services.tiktok.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php index e1959897..05b1c774 100644 --- a/app/Services/Social/TokenRefreshClient.php +++ b/app/Services/Social/TokenRefreshClient.php @@ -60,7 +60,13 @@ public function send(Closure $request): Response Log::error("TokenRefreshClient: {$name} token refresh failed", [ 'body' => $this->redactBody($response->body()), ]); - throw new TokenExpiredException("Failed to refresh {$name} token"); + + $body = $response->json(); + $message = data_get($body, 'error_description') + ?? data_get($body, 'error.message') + ?? "Failed to refresh {$name} token"; + + throw new TokenExpiredException($message, platformErrorCode: (string) $response->status()); } return $response; diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index ff8d07ed..4d620260 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -206,18 +207,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = $this->socialHttp() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => $this->socialHttp() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->asForm() ->post(config('trypost.platforms.x.api').'/oauth2/token', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - throw new TokenExpiredException('X token refresh failed'); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index ba9ef292..56f1a10a 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -335,19 +335,12 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for X account'); } - $response = Http::asForm() + $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) ->post("{$this->baseUrl}/oauth2/token", [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - throw new TokenExpiredException( - message: data_get($response->json(), 'error_description', 'Failed to refresh X token'), - platformErrorCode: (string) $response->status(), - ); - } + ])); $data = $response->json(); diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index 41f6a90c..f082667b 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; @@ -168,18 +169,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 1f67c6d0..3840766d 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -4,6 +4,7 @@ namespace App\Services\Social; +use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\YouTubePublishException; use App\Exceptions\TokenExpiredException; @@ -229,18 +230,13 @@ private function refreshToken(SocialAccount $account): void throw new TokenExpiredException('No refresh token available for YouTube account'); } - $response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ]); - - if ($response->failed()) { - Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($response->body())]); - - throw new TokenExpiredException('Failed to refresh YouTube token'); - } + $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() + ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ + 'client_id' => config('services.google.client_id'), + 'client_secret' => config('services.google.client_secret'), + 'grant_type' => 'refresh_token', + 'refresh_token' => $account->refresh_token, + ])); $data = $response->json(); diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 4f39d5ed..30d4c175 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -9,6 +9,7 @@ use App\Events\PostPlatformStatusUpdated; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\LinkedInPublishException; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\PublishToSocialPlatform; use App\Jobs\SendNotification; @@ -109,6 +110,28 @@ expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired); }); +test('publish to social platform does NOT mark account expired when platform is unavailable', function () { + Event::fake(); + Mail::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + ); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); + expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); + expect($this->socialAccount->status)->toBe(AccountStatus::Connected); +}); + test('publish to social platform updates post status when all platforms finished', function () { Event::fake(); From fcb70b3599f2623fe65042194a00b2e78badeada Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:18:58 -0300 Subject: [PATCH 31/65] refactor(social): single source of truth for token refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every per-platform publisher and analytics class had its own private refreshToken() implementation, all doing essentially the same thing as ConnectionVerifier's per-platform refresh*Token methods. ~17 copies of near-identical OAuth refresh logic across the codebase, which is how the 5xx→TokenExpired bug got planted in the publish path even after we fixed it on the hourly/daily jobs. Consolidation: - All publish/analytics paths call app(ConnectionVerifier::class) ->refreshToken($account) instead of their own implementation. - 17 private refreshToken() methods deleted. - refreshTokenWithLock helper removed from HasSocialHttpClient trait (its lock semantics are duplicated by ConnectionVerifier's per- account lock). - ConnectionVerifier::refreshLinkedInToken passes $account->platform to TokenRefreshClient so the "LinkedIn" vs "LinkedIn Page" label in error messages is preserved. - TokenRefreshClient now treats HTTP 429 the same as 5xx (raises PlatformUnavailableException) — replaces the retry-on-429 behavior that socialHttp() provided to the deleted refresh methods. Net: -460 LOC. Single per-platform refresh implementation. Every fix or new platform now lands in exactly one place. --- app/Services/Social/BlueskyPublisher.php | 47 +------------------ .../Social/Concerns/HasSocialHttpClient.php | 21 --------- app/Services/Social/ConnectionVerifier.php | 4 +- app/Services/Social/InstagramAnalytics.php | 30 +----------- app/Services/Social/InstagramPublisher.php | 27 +---------- app/Services/Social/LinkedInPageAnalytics.php | 30 +----------- app/Services/Social/LinkedInPagePublisher.php | 30 +----------- app/Services/Social/LinkedInPublisher.php | 30 +----------- app/Services/Social/PinterestAnalytics.php | 30 +----------- app/Services/Social/PinterestPublisher.php | 25 +--------- app/Services/Social/ThreadsAnalytics.php | 21 +-------- app/Services/Social/ThreadsPublisher.php | 24 +--------- app/Services/Social/TikTokAnalytics.php | 28 +---------- app/Services/Social/TikTokCreatorInfo.php | 28 +---------- app/Services/Social/TikTokPublisher.php | 28 +---------- app/Services/Social/TokenRefreshClient.php | 2 +- app/Services/Social/XAnalytics.php | 29 +----------- app/Services/Social/XPublisher.php | 26 +--------- app/Services/Social/YouTubeAnalytics.php | 30 +----------- app/Services/Social/YouTubePublisher.php | 28 +---------- 20 files changed, 29 insertions(+), 489 deletions(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index 68c5fc15..bc7a5b65 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -6,7 +6,6 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\BlueskyPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; @@ -30,7 +29,7 @@ public function publish(PostPlatform $postPlatform): array // Refresh token if needed if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -268,50 +267,6 @@ private function buildPostUrl(string $handle, string $postId): string return "https://bsky.app/profile/{$handle}/post/{$postId}"; } - public function refreshToken(SocialAccount $account): void - { - $service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service'); - $client = TokenRefreshClient::for(Platform::Bluesky); - - try { - $response = $client->send(fn () => $this->socialHttp()->withToken($account->refresh_token) - ->post("{$service}/xrpc/com.atproto.server.refreshSession")); - - $data = $response->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - return; - } catch (TokenExpiredException) { - // refresh token rejected (4xx) — fall back to re-auth below - } - - if (isset($account->meta['password'])) { - try { - $reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [ - 'identifier' => $account->meta['identifier'], - 'password' => decrypt($account->meta['password']), - ])); - - $data = $reauth->json(); - $account->update([ - 'access_token' => data_get($data, 'accessJwt'), - 'refresh_token' => data_get($data, 'refreshJwt'), - 'token_expires_at' => now()->addHours(2), - ]); - - return; - } catch (TokenExpiredException) { - // re-auth rejected with stored credentials — fall through - } - } - - throw new TokenExpiredException('Bluesky session expired'); - } - private function handleApiError(Response $response): never { throw BlueskyPublishException::fromApiResponse($response); diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index 301032a2..1aa41c7c 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -5,9 +5,7 @@ namespace App\Services\Social\Concerns; use App\Models\PostPlatform; -use App\Models\SocialAccount; use Illuminate\Http\Client\PendingRequest; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; trait HasSocialHttpClient @@ -28,25 +26,6 @@ protected function validateContentLength(PostPlatform $postPlatform): void ); } - protected function refreshTokenWithLock(SocialAccount $account, callable $refreshFn): void - { - $lock = Cache::lock("token_refresh:{$account->id}", 30); - - if (! $lock->get()) { - // Another process is refreshing, wait and reload - sleep(2); - $account->refresh(); - - return; - } - - try { - $refreshFn(); - } finally { - $lock->release(); - } - } - protected function socialHttp(): PendingRequest { return Http::retry( diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index d13bb620..a0b25dfb 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -112,10 +112,10 @@ public function refreshToken(SocialAccount $account): void private function refreshLinkedInToken(SocialAccount $account): void { if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for LinkedIn account'); + throw new TokenExpiredException("No refresh token available for {$account->platform->label()} account"); } - $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() + $response = TokenRefreshClient::for($account->platform)->send(fn () => Http::asForm() ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ 'grant_type' => 'refresh_token', 'refresh_token' => $account->refresh_token, diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index 126a4626..de677310 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -5,15 +5,12 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Carbon\CarbonInterface; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class InstagramAnalytics @@ -48,7 +45,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -92,7 +89,7 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si $this->baseUrl = $account->platform->instagramGraphBaseUrl(); if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -200,27 +197,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp(); } - - private function refreshToken(SocialAccount $account): void - { - if ($account->platform === Platform::InstagramFacebook) { - return; - } - - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for Instagram account'); - } - - $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ - 'grant_type' => 'ig_refresh_token', - 'access_token' => $account->access_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index 9ad27dd5..d4da8274 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -5,11 +5,9 @@ namespace App\Services\Social; use App\Enums\PostPlatform\ContentType; -use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\InstagramPublishException; use App\Models\PostPlatform; -use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; @@ -32,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $this->baseUrl = $account->platform->instagramGraphBaseUrl(); if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -395,29 +393,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken Log::warning('Instagram media processing timeout, proceeding anyway'); } - private function refreshToken(SocialAccount $account): void - { - // Instagram via Facebook uses page tokens that don't expire - if ($account->platform === Platform::InstagramFacebook) { - return; - } - - $response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [ - 'grant_type' => 'ig_refresh_token', - 'access_token' => $account->access_token, - ])); - - $data = $response->json(); - $newToken = data_get($data, 'access_token'); - - $account->update([ - 'access_token' => $newToken, - 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - - } - private function handleApiError(Response $response): never { throw InstagramPublishException::fromApiResponse($response); diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 98c1d519..3fdba549 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -4,15 +4,12 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Carbon\CarbonInterface; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class LinkedInPageAnalytics @@ -54,7 +51,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -84,7 +81,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -234,27 +231,4 @@ private function getHttpClient(): PendingRequest 'X-Restli-Protocol-Version' => '2.0.0', ]); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); - } - - $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 7486f5d3..8269a21b 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -46,7 +46,7 @@ public function publish(PostPlatform $postPlatform): array $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); + app(ConnectionVerifier::class)->refreshToken($this->account); $this->account->refresh(); } @@ -81,7 +81,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $this->hasRetried = true; try { - $this->refreshToken($this->account); + app(ConnectionVerifier::class)->refreshToken($this->account); $this->account->refresh(); $this->accessToken = $this->account->access_token; @@ -452,32 +452,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) Log::warning('LinkedIn Page video processing timeout, proceeding anyway'); } - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for LinkedIn Page account'); - } - - $response = TokenRefreshClient::for(Platform::LinkedInPage)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin-openid.client_id'), - 'client_secret' => config('services.linkedin-openid.client_secret'), - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - - // Sync tokens to LinkedIn personal if it exists - app(LinkedInTokenSynchronizer::class)->syncTokens($account); - } - private function handleApiError(Response $response): never { throw LinkedInPublishException::fromApiResponse($response); diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index ed6d230c..567bf1e0 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -46,7 +46,7 @@ public function publish(PostPlatform $postPlatform): array $this->hasRetried = false; if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { - $this->refreshTokenWithLock($this->account, fn () => $this->refreshToken($this->account)); + app(ConnectionVerifier::class)->refreshToken($this->account); $this->account->refresh(); } @@ -75,7 +75,7 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, $this->hasRetried = true; try { - $this->refreshToken($this->account); + app(ConnectionVerifier::class)->refreshToken($this->account); $this->account->refresh(); $this->accessToken = $this->account->access_token; @@ -433,32 +433,6 @@ private function waitForVideoProcessing(string $videoUrn, int $maxAttempts = 30) Log::warning('LinkedIn video processing timeout, proceeding anyway'); } - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for LinkedIn account'); - } - - $response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - 'client_id' => config('services.linkedin.client_id'), - 'client_secret' => config('services.linkedin.client_secret'), - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - - // Sync tokens to LinkedIn Page if it exists - app(LinkedInTokenSynchronizer::class)->syncTokens($account); - } - private function handleApiError(Response $response): never { throw LinkedInPublishException::fromApiResponse($response); diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index 2a343b9e..d18391a4 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -4,15 +4,12 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Carbon\CarbonInterface; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class PinterestAnalytics @@ -50,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -95,7 +92,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -161,27 +158,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp()->withToken($this->accessToken); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for Pinterest account'); - } - - $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withBasicAuth( - config('services.pinterest.client_id'), - config('services.pinterest.client_secret'), - )->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 116b93f5..5fe1d7b3 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -34,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -400,34 +400,13 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId, ); } - private function refreshToken(SocialAccount $account): void - { - $response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::asForm() - ->withBasicAuth( - config('services.pinterest.client_id'), - config('services.pinterest.client_secret') - ) - ->post($this->baseUrl.'/oauth/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : now()->addDays(30), - ]); - } - /** * Get user's boards for board selection. */ public function getBoards(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index dc245f63..26258979 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -4,14 +4,12 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Carbon\CarbonInterface; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ThreadsAnalytics @@ -49,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -81,7 +79,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -138,19 +136,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp(); } - - private function refreshToken(SocialAccount $account): void - { - $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ - 'grant_type' => 'th_refresh_token', - 'access_token' => $account->access_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 135f9dd4..28d3d19a 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -4,13 +4,10 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ThreadsPublishException; use App\Models\PostPlatform; -use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class ThreadsPublisher @@ -33,7 +30,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -303,25 +300,6 @@ private function waitForMediaProcessing(string $containerId, string $accessToken throw new \Exception('Threads media processing timeout after '.$maxAttempts.' attempts'); } - private function refreshToken(SocialAccount $account): void - { - // Threads uses long-lived tokens that can be refreshed - $response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [ - 'grant_type' => 'th_refresh_token', - 'access_token' => $account->access_token, - ])); - - $data = $response->json(); - - $newToken = data_get($data, 'access_token'); - - $account->update([ - 'access_token' => $newToken, - 'refresh_token' => $newToken, - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } - private function handleApiError(Response $response): never { throw ThreadsPublishException::fromApiResponse($response); diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index 7532ae3c..a1a80d63 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -4,13 +4,10 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class TikTokAnalytics @@ -39,7 +36,7 @@ public function getMetrics(SocialAccount $account): array private function fetchMetricsFromApi(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -160,27 +157,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp()->asJson()->withToken($this->accessToken); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for TikTok account'); - } - - $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index d3fe8ab5..418316bd 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -4,13 +4,10 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class TikTokCreatorInfo @@ -62,7 +59,7 @@ public function fetch(SocialAccount $account): array private function fetchFresh(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -124,27 +121,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp()->asJson()->withToken($this->accessToken); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for TikTok account'); - } - - $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index c0b875d0..554e3402 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -7,13 +7,11 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\TikTokPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\Response; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class TikTokPublisher @@ -38,7 +36,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -317,30 +315,6 @@ private function buildTikTokUrl(SocialAccount $account, ?string $postId = null): return null; } - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for TikTok account'); - } - - $response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [ - 'client_key' => config('services.tiktok.client_id'), - 'client_secret' => config('services.tiktok.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - - } - private function handleApiError(Response $response): never { throw TikTokPublishException::fromApiResponse($response); diff --git a/app/Services/Social/TokenRefreshClient.php b/app/Services/Social/TokenRefreshClient.php index 05b1c774..861389f7 100644 --- a/app/Services/Social/TokenRefreshClient.php +++ b/app/Services/Social/TokenRefreshClient.php @@ -49,7 +49,7 @@ public function send(Closure $request): Response throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}"); } - if ($response->serverError()) { + if ($response->serverError() || $response->status() === 429) { throw new PlatformUnavailableException( "{$name} API returned {$response->status()} during token refresh", $response->status(), diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 4d620260..1b22c7e9 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -4,8 +4,6 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -49,7 +47,7 @@ public function getMetrics(SocialAccount $account, ?CarbonInterface $since = nul private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -165,7 +163,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -200,27 +198,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp()->withToken($this->accessToken); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for X account'); - } - - $response = TokenRefreshClient::for(Platform::X)->send(fn () => $this->socialHttp() - ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) - ->asForm() - ->post(config('trypost.platforms.x.api').'/oauth2/token', [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index 56f1a10a..fefdda53 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -6,9 +6,7 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\XPublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; -use App\Models\SocialAccount; use App\Services\Media\MediaOptimizer; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\PendingRequest; @@ -39,7 +37,7 @@ public function publish(PostPlatform $postPlatform): array // Refresh token if expired or expiring soon if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -329,28 +327,6 @@ private function waitForProcessing(string $mediaId, int $maxAttempts = 20): bool return false; } - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for X account'); - } - - $response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm() - ->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret')) - ->post("{$this->baseUrl}/oauth2/token", [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => now()->addSeconds(data_get($data, 'expires_in', 7200)), - ]); - } - private function handleApiError(Response $response): never { throw XPublishException::fromApiResponse($response); diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index f082667b..43d01248 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -4,15 +4,12 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; use Carbon\CarbonInterface; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class YouTubeAnalytics @@ -50,7 +47,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array } if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -102,7 +99,7 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $since, CarbonInterface $until): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -162,27 +159,4 @@ private function getHttpClient(): PendingRequest { return $this->socialHttp()->withToken($this->accessToken); } - - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for YouTube account'); - } - - $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - } } diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 3840766d..b3a68fd6 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -4,10 +4,8 @@ namespace App\Services\Social; -use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\YouTubePublishException; -use App\Exceptions\TokenExpiredException; use App\Models\PostPlatform; use App\Models\SocialAccount; use App\Services\Social\Concerns\HasSocialHttpClient; @@ -36,7 +34,7 @@ public function publish(PostPlatform $postPlatform): array $account = $postPlatform->socialAccount; if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshTokenWithLock($account, fn () => $this->refreshToken($account)); + app(ConnectionVerifier::class)->refreshToken($account); $account->refresh(); } @@ -224,30 +222,6 @@ private function buildTitle(string $content): string return $title.$shortsTag; } - private function refreshToken(SocialAccount $account): void - { - if (! $account->refresh_token) { - throw new TokenExpiredException('No refresh token available for YouTube account'); - } - - $response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm() - ->post(config('trypost.platforms.youtube.oauth_api').'/token', [ - 'client_id' => config('services.google.client_id'), - 'client_secret' => config('services.google.client_secret'), - 'grant_type' => 'refresh_token', - 'refresh_token' => $account->refresh_token, - ])); - - $data = $response->json(); - - $account->update([ - 'access_token' => data_get($data, 'access_token'), - 'refresh_token' => data_get($data, 'refresh_token', $account->refresh_token), - 'token_expires_at' => data_get($data, 'expires_in') ? now()->addSeconds(data_get($data, 'expires_in')) : null, - ]); - - } - private function handleGoogleError(Exception $e): never { throw YouTubePublishException::fromGoogleException($e); From cf80e1fcaec925f5ea54c4a7e75ec4864254c994 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:21:43 -0300 Subject: [PATCH 32/65] refactor(social): single source of truth for token redaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape of bug as the refreshToken duplication: the regex that strips access_token / Bearer headers from logged HTTP bodies existed in three near-identical copies (HasSocialHttpClient trait, TokenRefreshClient, SocialPublishException), drifting subtly — SocialPublishException was missing the JSON "token" pattern. Extracts a TokenRedactor::redact(string) helper and routes all callers through it. Adding a new token format now means one regex in one file. --- .../Social/SocialPublishException.php | 25 ++------------ .../Social/Concerns/HasSocialHttpClient.php | 17 ++-------- app/Services/Social/TokenRedactor.php | 33 +++++++++++++++++++ app/Services/Social/TokenRefreshClient.php | 21 +----------- 4 files changed, 38 insertions(+), 58 deletions(-) create mode 100644 app/Services/Social/TokenRedactor.php diff --git a/app/Exceptions/Social/SocialPublishException.php b/app/Exceptions/Social/SocialPublishException.php index 384695e6..8d007728 100644 --- a/app/Exceptions/Social/SocialPublishException.php +++ b/app/Exceptions/Social/SocialPublishException.php @@ -4,6 +4,7 @@ namespace App\Exceptions\Social; +use App\Services\Social\TokenRedactor; use RuntimeException; abstract class SocialPublishException extends RuntimeException @@ -27,32 +28,10 @@ public function context(): array 'category' => $this->category->value, 'platform_error_code' => $this->platformErrorCode, 'user_message' => $this->userMessage, - 'raw_response' => $this->redactTokens($this->rawResponse), + 'raw_response' => $this->rawResponse !== null ? TokenRedactor::redact($this->rawResponse) : null, ]; } - private function redactTokens(?string $text): ?string - { - if ($text === null) { - return null; - } - - // Redact common token patterns from API error responses - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - ], - $text - ); - } - abstract public static function fromApiResponse(mixed $response): static; abstract public function platform(): string; diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index 1aa41c7c..092ca717 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -5,6 +5,7 @@ namespace App\Services\Social\Concerns; use App\Models\PostPlatform; +use App\Services\Social\TokenRedactor; use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; @@ -38,20 +39,6 @@ protected function socialHttp(): PendingRequest protected function redactResponseBody(string $body): string { - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - '/"token"\s*:\s*"([^"]+)"/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - '"token":"[REDACTED]"', - ], - $body - ); + return TokenRedactor::redact($body); } } diff --git a/app/Services/Social/TokenRedactor.php b/app/Services/Social/TokenRedactor.php new file mode 100644 index 00000000..bc154048 --- /dev/null +++ b/app/Services/Social/TokenRedactor.php @@ -0,0 +1,33 @@ +failed()) { Log::error("TokenRefreshClient: {$name} token refresh failed", [ - 'body' => $this->redactBody($response->body()), + 'body' => TokenRedactor::redact($response->body()), ]); $body = $response->json(); @@ -71,23 +71,4 @@ public function send(Closure $request): Response return $response; } - - private function redactBody(string $body): string - { - return preg_replace( - [ - '/access_token=([^&"\s]+)/', - '/"access_token"\s*:\s*"([^"]+)"/', - '/Bearer\s+\S+/', - '/"token"\s*:\s*"([^"]+)"/', - ], - [ - 'access_token=[REDACTED]', - '"access_token":"[REDACTED]"', - 'Bearer [REDACTED]', - '"token":"[REDACTED]"', - ], - $body - ); - } } From 123996bbc0c4e495042386a89a7d0461f52c2af8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:25:01 -0300 Subject: [PATCH 33/65] refactor(social): TokenRedactor::redact accepts nullable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the call sites drop the explicit null check ternary. The redactor itself owns the null handling — one less conditional at every caller. --- app/Exceptions/Social/SocialPublishException.php | 2 +- app/Services/Social/TokenRedactor.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/Exceptions/Social/SocialPublishException.php b/app/Exceptions/Social/SocialPublishException.php index 8d007728..2af28382 100644 --- a/app/Exceptions/Social/SocialPublishException.php +++ b/app/Exceptions/Social/SocialPublishException.php @@ -28,7 +28,7 @@ public function context(): array 'category' => $this->category->value, 'platform_error_code' => $this->platformErrorCode, 'user_message' => $this->userMessage, - 'raw_response' => $this->rawResponse !== null ? TokenRedactor::redact($this->rawResponse) : null, + 'raw_response' => TokenRedactor::redact($this->rawResponse), ]; } diff --git a/app/Services/Social/TokenRedactor.php b/app/Services/Social/TokenRedactor.php index bc154048..8da132e5 100644 --- a/app/Services/Social/TokenRedactor.php +++ b/app/Services/Social/TokenRedactor.php @@ -12,8 +12,12 @@ */ class TokenRedactor { - public static function redact(string $body): string + public static function redact(?string $body): ?string { + if ($body === null) { + return null; + } + return preg_replace( [ '/access_token=([^&"\s]+)/', From f975171a9a10630ecd3070f8579f1ab8fa3e59e9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:34:04 -0300 Subject: [PATCH 34/65] test(social): close coverage gaps for TokenRedactor and 429 handling - TokenRedactorTest (7 unit tests): all four regex patterns, multiple secrets in one body, null input, and the no-op pass-through case. Guards against silent regression of the redaction regexes (which previously drifted across three duplicated copies). - ConnectionVerifierTest: HTTP 429 during refresh raises PlatformUnavailableException, not TokenExpiredException. Locks in the rate-limit-as-transient behavior added when consolidating the refresh logic. --- .../Social/ConnectionVerifierTest.php | 15 ++++++ .../Services/Social/TokenRedactorTest.php | 47 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 tests/Unit/Services/Social/TokenRedactorTest.php diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 8488a972..34c94f92 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -409,6 +409,21 @@ expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class); }); +test('429 during refresh raises PlatformUnavailableException (rate limit is transient)', function () { + Http::fake([ + 'api.x.com/2/oauth2/token' => Http::response(['error' => 'rate_limit_exceeded'], 429), + ]); + + $account = SocialAccount::factory()->x()->create([ + 'token_expires_at' => now()->subMinutes(5), + 'refresh_token' => 'old_refresh_token', + ]); + + $verifier = new ConnectionVerifier; + + expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class); +}); + test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { Http::fake([ 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), diff --git a/tests/Unit/Services/Social/TokenRedactorTest.php b/tests/Unit/Services/Social/TokenRedactorTest.php new file mode 100644 index 00000000..f3e4df09 --- /dev/null +++ b/tests/Unit/Services/Social/TokenRedactorTest.php @@ -0,0 +1,47 @@ +toBe('POST https://api.example.com?access_token=[REDACTED]&page=1'); +}); + +test('redact strips access_token in JSON form', function () { + $input = '{"data":{"access_token":"abc123xyz","expires_in":3600}}'; + + expect(TokenRedactor::redact($input))->toBe('{"data":{"access_token":"[REDACTED]","expires_in":3600}}'); +}); + +test('redact strips Bearer authorization header', function () { + $input = "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig\n"; + + expect(TokenRedactor::redact($input))->toBe("Authorization: Bearer [REDACTED]\n"); +}); + +test('redact strips "token" JSON field', function () { + $input = '{"token":"shhh-secret","other":"keep"}'; + + expect(TokenRedactor::redact($input))->toBe('{"token":"[REDACTED]","other":"keep"}'); +}); + +test('redact handles multiple secrets in the same body', function () { + $input = 'access_token=one&refresh_token=two with Bearer xyz'; + $output = TokenRedactor::redact($input); + + expect($output)->toContain('access_token=[REDACTED]') + ->toContain('Bearer [REDACTED]'); +}); + +test('redact returns null when input is null', function () { + expect(TokenRedactor::redact(null))->toBeNull(); +}); + +test('redact returns the input unchanged when nothing matches', function () { + $input = 'plain log line with no secrets'; + + expect(TokenRedactor::redact($input))->toBe($input); +}); From 04975020e417802f35fa8f80afef7be5b0af1ea4 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:37:16 -0300 Subject: [PATCH 35/65] test(social): tests pull OAuth URLs from config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same rule applied to production code in earlier commits now applies to tests: Http::fake patterns and assertions read from config('trypost.platforms.*.oauth_api' / '.api' / '.default_service') instead of hardcoded strings. Hardcoded URLs in tests drift silently when the config changes. Also documents the rule in CLAUDE.md under "External Service URLs" so new code (and tests) start in the right place — only the host comes from config, path/RPC segments stay inline next to the call. --- CLAUDE.md | 7 +++++++ .../Social/ConnectionVerifierTest.php | 20 +++++++++++-------- .../Social/LinkedInPageAnalyticsTest.php | 12 +++++------ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 651d0c6f..f8e47ba8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,6 +302,13 @@ ## String Interpolation - Use curly braces `{}` even for simple variables to keep the boundary explicit and to allow object/array access without ambiguity. - Single quotes are still preferred when the string has no interpolation. +## External Service URLs + +- NEVER hardcode third-party API hosts, OAuth endpoints, or per-platform service URLs (e.g. `https://api.x.com/2`, `https://www.linkedin.com/oauth/v2/accessToken`, `https://bsky.social`). They live in `config/trypost.php` under `platforms.` with a matching `env(...)` default, so self-hosted users can override them and we have a single source of truth. + - Production code: `config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken'`, never the literal URL. + - Tests: use the same `config(...)` value in `Http::fake([...])` — `Http::fake([config('trypost.platforms.x.api').'/oauth2/token' => ...])`. Tests with hardcoded URLs drift silently when the config changes. + - Path/route segments after the host (e.g. `/oauth/v2/accessToken`, `/xrpc/com.atproto.server.refreshSession`) are part of the provider's protocol spec — those stay inline next to the call. Only the host comes from config. + ## TryPost.it Documentation - All our documentation to final user it's under https://docs.trypost.it diff --git a/tests/Feature/Services/Social/ConnectionVerifierTest.php b/tests/Feature/Services/Social/ConnectionVerifierTest.php index 34c94f92..4b16294d 100644 --- a/tests/Feature/Services/Social/ConnectionVerifierTest.php +++ b/tests/Feature/Services/Social/ConnectionVerifierTest.php @@ -364,14 +364,16 @@ }); test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () { + $service = config('trypost.platforms.bluesky.default_service'); + Http::fake([ - 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), + "{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503), ]); $account = SocialAccount::factory()->bluesky()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', - 'meta' => ['service' => 'https://bsky.social'], + 'meta' => ['service' => $service], ]); $verifier = new ConnectionVerifier; @@ -381,7 +383,7 @@ test('connection failure during refresh raises PlatformUnavailableException', function () { Http::fake([ - 'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), + config('trypost.platforms.youtube.oauth_api').'/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'), ]); $account = SocialAccount::factory()->youtube()->create([ @@ -396,7 +398,7 @@ test('4xx during refresh keeps raising TokenExpiredException', function () { Http::fake([ - 'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400), ]); $account = SocialAccount::factory()->x()->create([ @@ -411,7 +413,7 @@ test('429 during refresh raises PlatformUnavailableException (rate limit is transient)', function () { Http::fake([ - 'api.x.com/2/oauth2/token' => Http::response(['error' => 'rate_limit_exceeded'], 429), + config('trypost.platforms.x.api').'/oauth2/token' => Http::response(['error' => 'rate_limit_exceeded'], 429), ]); $account = SocialAccount::factory()->x()->create([ @@ -425,16 +427,18 @@ }); test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () { + $service = config('trypost.platforms.bluesky.default_service'); + Http::fake([ - 'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503), - 'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503), + "{$service}/xrpc/com.atproto.server.refreshSession" => Http::response('upstream timeout', 503), + "{$service}/xrpc/com.atproto.server.createSession" => Http::response('upstream timeout', 503), ]); $account = SocialAccount::factory()->bluesky()->create([ 'token_expires_at' => now()->subMinutes(5), 'refresh_token' => 'old_refresh_token', 'meta' => [ - 'service' => 'https://bsky.social', + 'service' => $service, 'identifier' => 'user.bsky.social', 'password' => encrypt('app-password'), ], diff --git a/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php index 0836940d..6805fd4d 100644 --- a/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php +++ b/tests/Feature/Services/Social/LinkedInPageAnalyticsTest.php @@ -34,13 +34,16 @@ }); test('linkedin page analytics refresh hits the configured oauth host', function () { + $oauthApi = config('trypost.platforms.linkedin.oauth_api'); + $api = config('trypost.platforms.linkedin-page.api'); + Http::fake([ - 'www.linkedin.com/oauth/v2/accessToken' => Http::response([ + "{$oauthApi}/oauth/v2/accessToken" => Http::response([ 'access_token' => 'new_token', 'refresh_token' => 'new_refresh_token', 'expires_in' => 5184000, ], 200), - 'api.linkedin.com/rest/socialActions/*' => Http::response([ + "{$api}/rest/socialActions/*" => Http::response([ 'likesSummary' => ['totalLikes' => 0], 'commentsSummary' => ['aggregatedTotalComments' => 0], ], 200), @@ -48,8 +51,5 @@ (new LinkedInPageAnalytics)->fetchPostMetrics($this->postPlatform); - Http::assertSent(fn ($request) => str_contains( - $request->url(), - rtrim((string) config('trypost.platforms.linkedin.oauth_api'), '/').'/oauth/v2/accessToken' - )); + Http::assertSent(fn ($request) => str_contains($request->url(), "{$oauthApi}/oauth/v2/accessToken")); }); From a42962c0cabdce3cd492b6f48113f9de0576d151 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:47:12 -0300 Subject: [PATCH 36/65] fix(social): publish retry honors PlatformUnavailable too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edge case from the prior commits: if the publisher throws TokenExpired (401 path), PublishToSocialPlatform attempts refreshAccountToken() to recover. That internally goes through ConnectionVerifier::verify, which can now raise PlatformUnavailable (5xx). The old catch (\Throwable) swallowed it but the loop still fell through to markAsTokenExpired — meaning a transient platform outage during a retry could still flip the account to expired. Adds an explicit PlatformUnavailable catch in the retry block: marks the post failed with category platform_unavailable and breaks before touching the account status. --- app/Jobs/PublishToSocialPlatform.php | 13 ++++++++ .../Jobs/PublishToSocialPlatformTest.php | 30 +++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index 0ee2b719..b37c40d1 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -130,6 +130,19 @@ public function handle(): void $this->refreshAccountToken(); continue; + } catch (PlatformUnavailableException $refreshError) { + Log::warning('Publish skipped: platform unavailable during retry refresh', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'error' => $refreshError->getMessage(), + ]); + + $this->postPlatform->markAsFailed($refreshError->getMessage(), [ + 'category' => 'platform_unavailable', + 'http_status' => $refreshError->httpStatus, + 'failed_at' => now()->toIso8601String(), + ]); + break; } catch (\Throwable $refreshError) { Log::error('Token refresh failed during publish retry', [ 'post_platform_id' => $this->postPlatform->id, diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 30d4c175..ecedf7af 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -110,6 +110,36 @@ expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired); }); +test('publish does NOT mark account expired when retry refresh hits platform unavailable', function () { + Event::fake(); + Mail::fake(); + + // Publisher first throws TokenExpired (401-style), the retry-refresh + // path goes through ConnectionVerifier::verify which can in turn raise + // PlatformUnavailable if the platform is down. The account must stay + // Connected — it was the platform that failed, not the token. + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401')); + + $verifier = Mockery::mock(ConnectionVerifier::class); + $verifier->shouldReceive('verify')->andThrow( + new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + ); + + $this->app->instance(LinkedInPublisher::class, $publisher); + $this->app->instance(ConnectionVerifier::class, $verifier); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); + expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); + expect($this->socialAccount->status)->toBe(AccountStatus::Connected); +}); + test('publish to social platform does NOT mark account expired when platform is unavailable', function () { Event::fake(); Mail::fake(); From 6c6078fa6a4aa0336275fa6761171939629a46d9 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 09:52:49 -0300 Subject: [PATCH 37/65] refactor(social): drop redundant \$account->refresh() after CV refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every refresh*Token method inside ConnectionVerifier already finishes with \$account->update(...) (mutates the model) + \$account->refresh() (reloads from DB to pick up sibling updates like LinkedInTokenSynchronizer). The lock-contention branch in refreshToken() also calls \$account->refresh() before returning. So a second \$account->refresh() in the caller was always a redundant SELECT — no scenario where it actually pulled a different value than what CV already left in memory. Removed from all 15 call sites, plus fixed the duplicated "Mastodon tokens don't expire" comment. --- app/Services/Social/BlueskyPublisher.php | 1 - app/Services/Social/ConnectionVerifier.php | 5 ++--- app/Services/Social/InstagramAnalytics.php | 2 -- app/Services/Social/InstagramPublisher.php | 1 - app/Services/Social/LinkedInPageAnalytics.php | 2 -- app/Services/Social/LinkedInPagePublisher.php | 2 -- app/Services/Social/LinkedInPublisher.php | 2 -- app/Services/Social/PinterestAnalytics.php | 2 -- app/Services/Social/PinterestPublisher.php | 2 -- app/Services/Social/ThreadsAnalytics.php | 2 -- app/Services/Social/ThreadsPublisher.php | 1 - app/Services/Social/TikTokAnalytics.php | 1 - app/Services/Social/TikTokCreatorInfo.php | 1 - app/Services/Social/TikTokPublisher.php | 1 - app/Services/Social/XAnalytics.php | 2 -- app/Services/Social/XPublisher.php | 1 - app/Services/Social/YouTubeAnalytics.php | 2 -- app/Services/Social/YouTubePublisher.php | 1 - 18 files changed, 2 insertions(+), 29 deletions(-) diff --git a/app/Services/Social/BlueskyPublisher.php b/app/Services/Social/BlueskyPublisher.php index bc7a5b65..38094fc6 100644 --- a/app/Services/Social/BlueskyPublisher.php +++ b/app/Services/Social/BlueskyPublisher.php @@ -30,7 +30,6 @@ public function publish(PostPlatform $postPlatform): array // Refresh token if needed if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $medias = $postPlatform->post->mediaItems; diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index a0b25dfb..7f25319f 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -99,9 +99,8 @@ public function refreshToken(SocialAccount $account): void Platform::Pinterest => $this->refreshPinterestToken($account), Platform::Threads => $this->refreshThreadsToken($account), Platform::Instagram => $this->refreshInstagramToken($account), - // InstagramFacebook uses page tokens that don't expire (like Facebook) - // Mastodon tokens don't expire - // Mastodon tokens don't expire + // Facebook / InstagramFacebook use Page tokens that don't expire. + // Mastodon tokens don't expire either. default => null, }; } finally { diff --git a/app/Services/Social/InstagramAnalytics.php b/app/Services/Social/InstagramAnalytics.php index de677310..50c20c88 100644 --- a/app/Services/Social/InstagramAnalytics.php +++ b/app/Services/Social/InstagramAnalytics.php @@ -46,7 +46,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; @@ -90,7 +89,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/InstagramPublisher.php b/app/Services/Social/InstagramPublisher.php index d4da8274..c22bef71 100644 --- a/app/Services/Social/InstagramPublisher.php +++ b/app/Services/Social/InstagramPublisher.php @@ -31,7 +31,6 @@ public function publish(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $instagramId = $account->platform_user_id; diff --git a/app/Services/Social/LinkedInPageAnalytics.php b/app/Services/Social/LinkedInPageAnalytics.php index 3fdba549..71fb4355 100644 --- a/app/Services/Social/LinkedInPageAnalytics.php +++ b/app/Services/Social/LinkedInPageAnalytics.php @@ -52,7 +52,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } // platform_post_id is the share URN (e.g., "urn:li:share:12345"). @@ -82,7 +81,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 8269a21b..c1a186f6 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -47,7 +47,6 @@ public function publish(PostPlatform $postPlatform): array if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($this->account); - $this->account->refresh(); } $this->accessToken = $this->account->access_token; @@ -82,7 +81,6 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, try { app(ConnectionVerifier::class)->refreshToken($this->account); - $this->account->refresh(); $this->accessToken = $this->account->access_token; $organizationId = $this->account->meta['organization_id'] ?? null; diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 567bf1e0..8053d458 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -47,7 +47,6 @@ public function publish(PostPlatform $postPlatform): array if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($this->account); - $this->account->refresh(); } $this->accessToken = $this->account->access_token; @@ -76,7 +75,6 @@ private function retryWithRefresh(PostPlatform $postPlatform, ?string $content, try { app(ConnectionVerifier::class)->refreshToken($this->account); - $this->account->refresh(); $this->accessToken = $this->account->access_token; $personUrn = "urn:li:person:{$this->account->platform_user_id}"; diff --git a/app/Services/Social/PinterestAnalytics.php b/app/Services/Social/PinterestAnalytics.php index d18391a4..082963a2 100644 --- a/app/Services/Social/PinterestAnalytics.php +++ b/app/Services/Social/PinterestAnalytics.php @@ -48,7 +48,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $start = now()->subDays(90)->format('Y-m-d'); @@ -93,7 +92,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/PinterestPublisher.php b/app/Services/Social/PinterestPublisher.php index 5fe1d7b3..994199fa 100644 --- a/app/Services/Social/PinterestPublisher.php +++ b/app/Services/Social/PinterestPublisher.php @@ -35,7 +35,6 @@ public function publish(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $content = $postPlatform->post->content ? app(ContentSanitizer::class)->sanitize($postPlatform->post->content, $postPlatform->platform) : null; @@ -407,7 +406,6 @@ public function getBoards(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $response = $this->socialHttp()->withToken($account->access_token) diff --git a/app/Services/Social/ThreadsAnalytics.php b/app/Services/Social/ThreadsAnalytics.php index 26258979..ef196477 100644 --- a/app/Services/Social/ThreadsAnalytics.php +++ b/app/Services/Social/ThreadsAnalytics.php @@ -48,7 +48,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $response = $this->socialHttp() @@ -80,7 +79,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 28d3d19a..c37402f1 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -31,7 +31,6 @@ public function publish(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $userId = $account->platform_user_id; diff --git a/app/Services/Social/TikTokAnalytics.php b/app/Services/Social/TikTokAnalytics.php index a1a80d63..37861978 100644 --- a/app/Services/Social/TikTokAnalytics.php +++ b/app/Services/Social/TikTokAnalytics.php @@ -37,7 +37,6 @@ private function fetchMetricsFromApi(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/TikTokCreatorInfo.php b/app/Services/Social/TikTokCreatorInfo.php index 418316bd..7571d067 100644 --- a/app/Services/Social/TikTokCreatorInfo.php +++ b/app/Services/Social/TikTokCreatorInfo.php @@ -60,7 +60,6 @@ private function fetchFresh(SocialAccount $account): array { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/TikTokPublisher.php b/app/Services/Social/TikTokPublisher.php index 554e3402..d26a8a8a 100644 --- a/app/Services/Social/TikTokPublisher.php +++ b/app/Services/Social/TikTokPublisher.php @@ -37,7 +37,6 @@ public function publish(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/XAnalytics.php b/app/Services/Social/XAnalytics.php index 1b22c7e9..c562b2a8 100644 --- a/app/Services/Social/XAnalytics.php +++ b/app/Services/Social/XAnalytics.php @@ -48,7 +48,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; @@ -164,7 +163,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index fefdda53..625a840b 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -38,7 +38,6 @@ public function publish(PostPlatform $postPlatform): array // Refresh token if expired or expiring soon if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/YouTubeAnalytics.php b/app/Services/Social/YouTubeAnalytics.php index 43d01248..c4eb8342 100644 --- a/app/Services/Social/YouTubeAnalytics.php +++ b/app/Services/Social/YouTubeAnalytics.php @@ -48,7 +48,6 @@ public function fetchPostMetrics(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; @@ -100,7 +99,6 @@ private function fetchMetricsFromApi(SocialAccount $account, CarbonInterface $si { if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $this->accessToken = $account->access_token; diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index b3a68fd6..5b4467c5 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -35,7 +35,6 @@ public function publish(PostPlatform $postPlatform): array if ($account->is_token_expired || $account->is_token_expiring_soon) { app(ConnectionVerifier::class)->refreshToken($account); - $account->refresh(); } $media = $postPlatform->post->mediaItems; From d336f790594f54b148df7f3d2197fb61fec4e3d7 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 10:03:30 -0300 Subject: [PATCH 38/65] feat(social): reschedule publish on PlatformUnavailable instead of failing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: a scheduled post hitting a platform outage was marked Failed — user had to manually retry. Now the job reschedules itself for 10 minutes later and the PostPlatform shows status "Retrying". Loops indefinitely until the platform accepts the post. - Adds PostPlatformStatus::Retrying (existing string column, no migration) - PublishToSocialPlatform: PlatformUnavailable catch now calls rescheduleForRetry() which (a) updates the row to Retrying with retry_count + next_attempt_at in error_context, and (b) dispatches itself with a 10-minute delay. updatePostStatus() naturally leaves the parent Post in Publishing because Retrying is neither Published nor Failed. - Same treatment for the retry-refresh edge case (publisher throws TokenExpired, refresh subsequently fails with PlatformUnavailable). - i18n + frontend status config updated (en, pt-BR, es) for both posts.status.retrying and posts.edit.status.retrying. - Tests: 3 new tests covering the dispatched job, the edge case path, and retry_count increment across attempts. --- app/Enums/PostPlatform/Status.php | 1 + app/Jobs/PublishToSocialPlatform.php | 52 +++++++++------- lang/en/posts.php | 2 + lang/es/posts.php | 2 + lang/php_en.json | 2 +- lang/php_es.json | 2 +- lang/php_pt-BR.json | 2 +- lang/pt-BR/posts.php | 2 + resources/js/composables/usePostStatus.ts | 2 + .../Jobs/PublishToSocialPlatformTest.php | 59 ++++++++++++++----- 10 files changed, 87 insertions(+), 39 deletions(-) diff --git a/app/Enums/PostPlatform/Status.php b/app/Enums/PostPlatform/Status.php index 96501e52..354c683b 100644 --- a/app/Enums/PostPlatform/Status.php +++ b/app/Enums/PostPlatform/Status.php @@ -8,6 +8,7 @@ enum Status: string { case Pending = 'pending'; case Publishing = 'publishing'; + case Retrying = 'retrying'; case Published = 'published'; case Failed = 'failed'; } diff --git a/app/Jobs/PublishToSocialPlatform.php b/app/Jobs/PublishToSocialPlatform.php index b37c40d1..c040f6f0 100644 --- a/app/Jobs/PublishToSocialPlatform.php +++ b/app/Jobs/PublishToSocialPlatform.php @@ -112,17 +112,7 @@ public function handle(): void $this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url')); break; } catch (PlatformUnavailableException $e) { - Log::warning('Publish skipped: platform unavailable', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $e->getMessage(), - ]); - - $this->postPlatform->markAsFailed($e->getMessage(), [ - 'category' => 'platform_unavailable', - 'http_status' => $e->httpStatus, - 'failed_at' => now()->toIso8601String(), - ]); + $this->rescheduleForRetry($e); break; } catch (TokenExpiredException $e) { if ($attempt < $maxAttempts) { @@ -131,17 +121,7 @@ public function handle(): void continue; } catch (PlatformUnavailableException $refreshError) { - Log::warning('Publish skipped: platform unavailable during retry refresh', [ - 'post_platform_id' => $this->postPlatform->id, - 'platform' => $this->postPlatform->platform->value, - 'error' => $refreshError->getMessage(), - ]); - - $this->postPlatform->markAsFailed($refreshError->getMessage(), [ - 'category' => 'platform_unavailable', - 'http_status' => $refreshError->httpStatus, - 'failed_at' => now()->toIso8601String(), - ]); + $this->rescheduleForRetry($refreshError); break; } catch (\Throwable $refreshError) { Log::error('Token refresh failed during publish retry', [ @@ -208,6 +188,34 @@ private function refreshAccountToken(): void app(ConnectionVerifier::class)->verify($account); } + private function rescheduleForRetry(PlatformUnavailableException $e): void + { + $retryCount = (int) ($this->postPlatform->error_context['retry_count'] ?? 0) + 1; + $nextAttemptAt = now()->addMinutes(10); + + Log::warning('Publish rescheduled: platform unavailable', [ + 'post_platform_id' => $this->postPlatform->id, + 'platform' => $this->postPlatform->platform->value, + 'retry_count' => $retryCount, + 'next_attempt_at' => $nextAttemptAt->toIso8601String(), + 'error' => $e->getMessage(), + ]); + + $this->postPlatform->update([ + 'status' => PostPlatformStatus::Retrying, + 'error_message' => $e->getMessage(), + 'error_context' => [ + 'category' => 'platform_unavailable', + 'http_status' => $e->httpStatus, + 'retry_count' => $retryCount, + 'last_attempt_at' => now()->toIso8601String(), + 'next_attempt_at' => $nextAttemptAt->toIso8601String(), + ], + ]); + + self::dispatch($this->postPlatform)->delay($nextAttemptAt); + } + private function broadcastStatus(): void { PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh()); diff --git a/lang/en/posts.php b/lang/en/posts.php index ffa00259..573d0192 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -175,6 +175,7 @@ 'draft' => 'Draft', 'scheduled' => 'Scheduled', 'publishing' => 'Publishing', + 'retrying' => 'Retrying', 'published' => 'Published', 'partially_published' => 'Partially Published', 'failed' => 'Failed', @@ -323,6 +324,7 @@ 'scheduled' => 'Scheduled', 'published' => 'Published', 'publishing' => 'Publishing...', + 'retrying' => 'Retrying...', 'failed' => 'Failed', ], diff --git a/lang/es/posts.php b/lang/es/posts.php index 7da4d67a..42cadbcb 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -175,6 +175,7 @@ 'draft' => 'Borrador', 'scheduled' => 'Programado', 'publishing' => 'Publicando', + 'retrying' => 'Reintentando', 'published' => 'Publicado', 'partially_published' => 'Parcialmente publicado', 'failed' => 'Fallido', @@ -323,6 +324,7 @@ 'scheduled' => 'Programado', 'published' => 'Publicado', 'publishing' => 'Publicando...', + 'retrying' => 'Reintentando...', 'failed' => 'Fallido', ], diff --git a/lang/php_en.json b/lang/php_en.json index adf01b91..981c6c15 100644 --- a/lang/php_en.json +++ b/lang/php_en.json @@ -1 +1 @@ -{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.any_of":"The :attribute field is invalid.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_contain":"The :attribute field must not contain any of the following: :values.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.encoding":"The :attribute field must be encoded in :encoding.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.in_array_keys":"The :attribute field must contain at least one of the following keys: :values.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_if_accepted":"The :attribute field is prohibited when :other is accepted.","validation.prohibited_if_declined":"The :attribute field is prohibited when :other is declined.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","accounts.title":"Connections","accounts.page_title":"Social Accounts","accounts.description":"Overview of all your connected social accounts","accounts.add_social":"Add Social","accounts.add_social_title":"Connect a Social Account","accounts.add_social_description":"Connect a social account to TryPost to start posting","accounts.connect_cta":"Connect","accounts.no_accounts":"No accounts connected yet","accounts.no_accounts_description":"Connect your social networks to start scheduling and publishing posts","accounts.no_search_results":"No accounts match your search","accounts.try_different_search":"Try a different keyword or clear the search.","accounts.search":"Search accounts...","accounts.added":"Added :date","accounts.limit_reached":"You have reached your plan limit for social accounts.","accounts.not_connected":"Not connected","accounts.connect":"Connect","accounts.connection_lost":"Connection lost","accounts.reconnect_account":"Reconnect account","accounts.view_profile":"View profile","accounts.disconnect":"Disconnect","accounts.table.account":"Account","accounts.table.platform":"Platform","accounts.table.status":"Status","accounts.table.last_used":"Last used","accounts.table.added":"Added","accounts.table.active":"Active","accounts.never_used":"Never used","accounts.status.connected":"Connected","accounts.status.disconnected":"Disconnected","accounts.descriptions.linkedin":"Connect your LinkedIn personal profile","accounts.descriptions.linkedin-page":"Connect a LinkedIn company page","accounts.descriptions.x":"Connect your X (Twitter) account","accounts.descriptions.tiktok":"Connect your TikTok account","accounts.descriptions.youtube":"Connect a YouTube channel","accounts.descriptions.facebook":"Connect a Facebook page","accounts.descriptions.instagram":"Connect an Instagram professional account","accounts.descriptions.instagram-facebook":"Connect Instagram via Facebook page","accounts.descriptions.threads":"Connect your Threads account","accounts.descriptions.pinterest":"Connect your Pinterest account","accounts.descriptions.bluesky":"Connect your Bluesky account","accounts.descriptions.mastodon":"Connect your Mastodon account","accounts.disconnect_modal.title":"Disconnect Account","accounts.disconnect_modal.description":"Are you sure you want to disconnect this account? You can reconnect it at any time.","accounts.disconnect_modal.confirm":"Disconnect","accounts.disconnect_modal.cancel":"Cancel","accounts.bluesky.title":"Connect Bluesky","accounts.bluesky.description":"Enter your credentials to connect","accounts.bluesky.email":"Email","accounts.bluesky.email_placeholder":"yourhandle.bsky.social","accounts.bluesky.app_password":"App Password","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use an App Password for security. Create one at bsky.app/settings.","accounts.bluesky.submit":"Connect Bluesky","accounts.bluesky.submitting":"Connecting...","accounts.mastodon.title":"Connect Mastodon","accounts.mastodon.description":"Enter your Mastodon instance","accounts.mastodon.instance_url":"Instance URL","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)","accounts.mastodon.submit":"Continue with Mastodon","accounts.mastodon.submitting":"Connecting...","accounts.facebook.title":"Select Facebook Page","accounts.facebook.description":"Choose which page you want to connect","accounts.facebook.no_pages":"No pages found","accounts.facebook.no_pages_description":"You are not an admin of any Facebook page.","accounts.facebook.page_label":"Facebook Page","accounts.instagram_facebook.title":"Select Instagram Account","accounts.instagram_facebook.description":"Choose which Instagram account you want to connect","accounts.instagram_facebook.no_pages":"No Instagram accounts found","accounts.instagram_facebook.no_pages_description":"No Facebook Pages with linked Instagram Business accounts were found.","accounts.linkedin.title":"Select LinkedIn Page","accounts.linkedin.description":"Choose which page you want to connect","accounts.linkedin.no_pages":"No pages found","accounts.linkedin.no_pages_description":"You are not an administrator of any LinkedIn page.","accounts.linkedin.page_label":"LinkedIn Page","accounts.flash.disconnected":"Account disconnected successfully!","accounts.flash.connected":"Account connected successfully!","accounts.flash.session_expired":"Session expired. Please try again.","accounts.flash.workspace_not_found":"Workspace not found.","accounts.flash.activated":"Account activated!","accounts.flash.deactivated":"Account deactivated!","accounts.flash.already_connected":"This platform is already connected.","accounts.flash.no_youtube_channels":"No YouTube channels found. Please create a channel first.","accounts.popup_callback.title_success":"Connected","accounts.popup_callback.title_error":"Error","accounts.popup_callback.closing":"This window will close automatically...","accounts.popup_callback.close_now":"You can close this window now.","accounts.popup_callback.connected":"Account connected!","accounts.popup_callback.reconnected":"Account reconnected!","accounts.popup_callback.error_connecting":"Error connecting account. Please try again.","accounts.popup_callback.error_connecting_page":"Error connecting page. Please try again.","accounts.popup_callback.error_connecting_channel":"Error connecting channel. Please try again.","accounts.popup_callback.session_expired":"Session expired. Please try again.","accounts.popup_callback.workspace_not_found":"Workspace not found.","accounts.popup_callback.invalid_state":"Invalid state. Please try again.","accounts.popup_callback.failed_to_authenticate":"Failed to authenticate.","accounts.popup_callback.failed_to_get_profile":"Failed to get profile.","accounts.popup_callback.page_not_found":"Page not found.","accounts.popup_callback.channel_not_found":"Channel not found.","accounts.popup_callback.no_facebook_pages":"No Facebook Pages found. You need to be an admin of at least one page.","accounts.popup_callback.no_facebook_instagram_pages":"No Facebook Pages with linked Instagram accounts found.","accounts.popup_callback.no_youtube_channels":"No YouTube channels found. Please create a channel first.","accounts.popup_callback.not_linkedin_admin":"You are not an administrator of any LinkedIn page.","analytics.no_accounts":"No connected accounts with analytics.","analytics.no_accounts_match":"No accounts match.","analytics.search_account":"Search account…","analytics.select_account":"Select an account to view analytics.","analytics.no_data":"No analytics data available.","analytics.metrics.avg_view_duration":"Avg. View Duration (s)","analytics.metrics.avg_view_percentage":"Avg. View Percentage","analytics.metrics.bookmarks":"Bookmarks","analytics.metrics.clicks":"Clicks","analytics.metrics.comments":"Comments","analytics.metrics.engagement":"Engagement","analytics.metrics.favourites":"Favourites","analytics.metrics.followers":"Followers","analytics.metrics.following":"Following","analytics.metrics.impressions":"Impressions","analytics.metrics.interactions":"Interactions","analytics.metrics.likes":"Likes","analytics.metrics.minutes_watched":"Minutes Watched","analytics.metrics.organic_followers":"Organic Followers","analytics.metrics.outbound_clicks":"Outbound Clicks","analytics.metrics.page_followers":"Page Followers","analytics.metrics.page_reach":"Page Reach","analytics.metrics.page_views":"Page Views","analytics.metrics.paid_followers":"Paid Followers","analytics.metrics.pin_click_rate":"Pin Click Rate","analytics.metrics.pin_clicks":"Pin Clicks","analytics.metrics.posts_engagement":"Posts Engagement","analytics.metrics.posts_reach":"Posts Reach","analytics.metrics.quotes":"Quotes","analytics.metrics.reach":"Reach","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Recent Comments","analytics.metrics.recent_likes":"Recent Likes","analytics.metrics.recent_shares":"Recent Shares","analytics.metrics.replies":"Replies","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Saves","analytics.metrics.shares":"Shares","analytics.metrics.subscribers_gained":"Subscribers Gained","analytics.metrics.subscribers_lost":"Subscribers Lost","analytics.metrics.total_likes":"Total Likes","analytics.metrics.video_views":"Video Views","analytics.metrics.videos":"Videos","analytics.metrics.views":"Views","assets.title":"Assets","assets.tabs.my_uploads":"My Uploads","assets.tabs.stock_photos":"Stock Photos","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Drag & drop your files here, or click to select","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Uploading...","assets.empty.title":"No assets yet","assets.empty.description":"Upload images and videos to build your media library.","assets.save_to_assets":"Save to Assets","assets.saved":"Saved to your assets!","assets.create_post":"Create post","assets.add_to_post":"Add to post","assets.search_placeholder":"Search media...","assets.delete.title":"Delete asset","assets.delete.description":"Are you sure you want to delete this asset? This action cannot be undone.","assets.delete.confirm":"Delete","assets.delete.cancel":"Cancel","assets.unsplash.search_placeholder":"Search free photos...","assets.unsplash.no_results":"No photos found","assets.unsplash.no_results_description":"Try a different search term.","assets.unsplash.trending":"Trending on Unsplash","assets.unsplash.start_searching":"Search for free stock photos from Unsplash","assets.giphy.trending":"Trending on Giphy","assets.giphy.search_placeholder":"Search GIFs...","assets.giphy.no_results":"No GIFs found","assets.giphy.no_results_description":"Try a different search term.","assets.giphy.powered_by":"Powered by GIPHY","auth.flash.welcome":"Welcome to TryPost!","auth.flash.welcome_trial":"Welcome to TryPost! Your trial has started.","auth.legal":"By continuing, you agree to our Terms of Service and Privacy Policy.","auth.slides.calendar.title":"Visual Calendar","auth.slides.calendar.description":"Plan and schedule your content with an intuitive drag-and-drop calendar across all your social accounts.","auth.slides.scheduling.title":"Smart Scheduling","auth.slides.scheduling.description":"Schedule posts across LinkedIn, X, Instagram, TikTok, YouTube, and more — all from one place.","auth.slides.media.title":"Rich Media","auth.slides.media.description":"Publish images, carousels, stories, and reels. Each platform gets the right format automatically.","auth.slides.video.title":"Video Publishing","auth.slides.video.description":"Upload videos once and publish to TikTok, YouTube Shorts, Instagram Reels, and Facebook Reels.","auth.slides.team.title":"Team Workspaces","auth.slides.team.description":"Invite your team, assign roles, and manage multiple brands from separate workspaces.","auth.slides.signatures.title":"Signatures","auth.slides.signatures.description":"Save reusable signatures (hashtags, links, signoffs) and append them to posts with one click.","auth.or_continue_with":"Or continue with","auth.google_login":"Log in with Google","auth.google_signup":"Sign up with Google","auth.github_login":"Log in with GitHub","auth.github_signup":"Sign up with GitHub","auth.github_email_unavailable":"Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.","auth.signup_success.page_title":"Welcome","auth.signup_success.title":"Setting up your account","auth.signup_success.description":"This usually takes just a few seconds...","auth.login.title":"Log in to your account","auth.login.description":"Enter your email and password below to log in","auth.login.page_title":"Log in","auth.login.email":"Email address","auth.login.password":"Password","auth.login.forgot_password":"Forgot password?","auth.login.remember_me":"Remember me","auth.login.submit":"Log in","auth.login.no_account":"Don't have an account?","auth.login.sign_up":"Sign up","auth.register.title":"Create an account","auth.register.description":"Enter your details below to create your account","auth.register.page_title":"Register","auth.register.name":"Name","auth.register.name_placeholder":"Full name","auth.register.email":"Email address","auth.register.password":"Password","auth.register.show_password":"Show password","auth.register.hide_password":"Hide password","auth.register.submit":"Create account","auth.register.has_account":"Already have an account?","auth.register.log_in":"Log in","auth.forgot_password.title":"Forgot password","auth.forgot_password.description":"Enter your email to receive a password reset link","auth.forgot_password.page_title":"Forgot password","auth.forgot_password.email":"Email address","auth.forgot_password.submit":"Email password reset link","auth.forgot_password.return_to":"Or, return to","auth.forgot_password.log_in":"log in","auth.reset_password.title":"Reset password","auth.reset_password.description":"Please enter your new password below","auth.reset_password.page_title":"Reset password","auth.reset_password.email":"Email","auth.reset_password.password":"Password","auth.reset_password.confirm_password":"Confirm Password","auth.reset_password.confirm_placeholder":"Confirm password","auth.reset_password.submit":"Reset password","auth.verify_email.title":"Verify email","auth.verify_email.description":"Please verify your email address by clicking on the link we just emailed to you.","auth.verify_email.page_title":"Email verification","auth.verify_email.link_sent":"A new verification link has been sent to the email address you provided during registration.","auth.verify_email.resend":"Resend verification email","auth.verify_email.log_out":"Log out","auth.accept_invite.page_title":"Accept Invite","auth.accept_invite.title":"You've been invited!","auth.accept_invite.description":"You've been invited to join the :workspace workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Your role","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Accept Invite","auth.accept_invite.decline":"Decline Invite","auth.accept_invite.login_prompt":"Log in or create an account to accept this invite.","auth.accept_invite.log_in":"Log in","auth.accept_invite.create_account":"Create Account","billing.title":"Billing","billing.upgrade_dialog.title":"Upgrade your plan","billing.upgrade_dialog.description":"Pick a plan that fits your needs.","billing.upgrade_dialog.current_plan":"Current plan","billing.upgrade_dialog.current_short":"Current","billing.upgrade_dialog.current_badge":"Current","billing.upgrade_dialog.subscribe":"Subscribe","billing.upgrade_dialog.switch":"Switch to this plan","billing.upgrade_dialog.switch_short":"Switch","billing.upgrade_dialog.switch_to_yearly":"Switch to yearly","billing.upgrade_dialog.switch_to_monthly":"Switch to monthly","billing.upgrade_dialog.unavailable":"Unavailable","billing.upgrade_dialog.reasons.workspace_limit":"You've reached the workspace limit on your current plan. Upgrade to create more workspaces.","billing.upgrade_dialog.reasons.social_account_limit":"You've reached the social account limit on your current plan. Upgrade to connect more accounts.","billing.upgrade_dialog.reasons.member_limit":"You've reached the team member limit on your current plan. Upgrade to invite more people.","billing.subscribe.page_title":"Choose your plan","billing.subscribe.eyebrow":"Pricing","billing.subscribe.title":"Choose the right plan for you","billing.subscribe.description":"Pick the plan that fits you. Billed monthly or annually.","billing.subscribe.monthly":"Monthly","billing.subscribe.yearly":"Yearly","billing.subscribe.per_month":"monthly","billing.subscribe.per_year":"yearly","billing.subscribe.billed_monthly":"Billed monthly","billing.subscribe.billed_yearly":"Billed annually","billing.subscribe.features_included":"What's included:","billing.subscribe.everything_in":"Everything in :plan, plus:","billing.subscribe.save_months":"2 months free","billing.subscribe.popular":"Most popular","billing.subscribe.subscribe_cta":"Subscribe","billing.subscribe.prices.starter.monthly":"$19","billing.subscribe.prices.starter.yearly_per_month":"$16","billing.subscribe.prices.starter.yearly":"$190","billing.subscribe.prices.plus.monthly":"$29","billing.subscribe.prices.plus.yearly_per_month":"$24","billing.subscribe.prices.plus.yearly":"$290","billing.subscribe.prices.pro.monthly":"$49","billing.subscribe.prices.pro.yearly_per_month":"$41","billing.subscribe.prices.pro.yearly":"$490","billing.subscribe.prices.max.monthly":"$99","billing.subscribe.prices.max.yearly_per_month":"$83","billing.subscribe.prices.max.yearly":"$990","billing.subscribe.features.social_accounts":":count social accounts","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count team members","billing.subscribe.features.credits":":count AI credits/mo","billing.subscribe.credit_tooltips.starter":"Roughly 150 medium-length posts plus 5 AI images per month.","billing.subscribe.credit_tooltips.plus":"Roughly 300 medium-length posts plus 10 AI images per month.","billing.subscribe.credit_tooltips.pro":"Roughly 700 medium-length posts plus 30 AI images per month.","billing.subscribe.credit_tooltips.max":"Roughly 2,000 medium-length posts plus 100 AI images per month.","billing.plan.title":"Plan","billing.plan.description":"Manage your subscription plan.","billing.plan.change":"Change plan","billing.plan.label":"Plan","billing.plan.price":"Price","billing.plan.month":"month","billing.plan.trial":"Trial","billing.plan.active":"Active","billing.plan.past_due":"Past due","billing.plan.cancelling":"Cancelling","billing.plan.trial_ends":"Trial ends","billing.subscription.title":"Subscription","billing.subscription.description":"Manage your payment method, billing details, and subscription.","billing.subscription.payment_method":"Payment method","billing.subscription.no_payment_method":"No payment method on file yet.","billing.subscription.expires_on":"Expires :month/:year","billing.subscription.manage_label":"Subscription","billing.subscription.manage_stripe":"Manage on Stripe","billing.invoices.title":"Invoices","billing.invoices.description":"Download your past invoices.","billing.invoices.empty":"No invoices found","billing.invoices.paid":"Paid","billing.flash.plan_changed":"You are now on the :plan plan.","billing.flash.cannot_manage":"Only the account owner can manage billing.","billing.flash.cannot_downgrade.workspaces":"Cannot switch to :plan: you have :count workspaces but the plan only allows :limit.","billing.flash.cannot_downgrade.social_accounts":"Cannot switch to :plan: you have :count social accounts but the plan only allows :limit.","billing.flash.cannot_downgrade.members":"Cannot switch to :plan: you have :count team members (including invites) but the plan only allows :limit.","billing.flash.credits_exhausted":"Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.","billing.processing.page_title":"Processing...","billing.processing.title":"Processing your subscription","billing.processing.description":"Please wait while we set up your account. This will only take a moment.","billing.processing.success_title":"You're all set!","billing.processing.success_description":"Your subscription is active. Redirecting you to your workspaces...","billing.processing.cancelled_title":"Checkout cancelled","billing.processing.cancelled_description":"Your checkout was cancelled. No charges were made.","billing.processing.retry":"Try again","brands.new_brand":"New Brand","brands.no_brands_yet":"No brands yet","brands.no_brands_description":"Create brands to organize your social accounts by client or project","brands.accounts_count":":count accounts","brands.create.title":"Create Brand","brands.create.description":"Give your brand a name to group social accounts","brands.create.name":"Brand Name","brands.create.name_placeholder":"e.g. Acme Corp, Personal","brands.create.submit":"Create Brand","brands.create.submitting":"Creating...","brands.edit.title":"Edit Brand","brands.edit.description":"Update the name of this brand","brands.edit.name":"Brand Name","brands.edit.name_placeholder":"e.g. Acme Corp, Personal","brands.edit.submit":"Save Changes","brands.edit.submitting":"Saving...","brands.delete.title":"Delete Brand","brands.delete.description":"Are you sure you want to delete this brand? Social accounts will be unassigned but not deleted.","brands.delete.confirm":"Delete","brands.delete.cancel":"Cancel","brands.flash.created":"Brand created successfully!","brands.flash.updated":"Brand updated successfully!","brands.flash.deleted":"Brand deleted successfully!","calendar.title":"Calendar","calendar.today":"Today","calendar.day":"Day","calendar.week":"Week","calendar.month":"Month","calendar.new_post":"New Post","calendar.no_content":"No content","calendar.more":"+:count more","comments.placeholder":"Write a comment...","comments.reply_placeholder":"Write a reply...","comments.reply":"Reply","comments.edit":"Edit","comments.delete":"Delete","comments.edited":"edited","comments.save":"Save","comments.cancel":"Cancel","comments.send":"Send","comments.replying_to":"Replying to :name","comments.empty":"No comments yet. Start the conversation.","comments.load_more":"Load older comments","comments.today":"Today","comments.yesterday":"Yesterday","common.confirm_modal.cannot_be_undone":"This cannot be undone.","common.confirm_modal.type":"Type","common.confirm_modal.to_confirm":"to confirm.","common.confirm_modal.copy_to_clipboard":"Copy to clipboard","common.confirm_modal.delete_keyword":"delete","common.photo_upload.upload":"Upload","common.photo_upload.uploading":"Uploading...","common.photo_upload.remove":"Remove photo","common.photo_upload.hint":"Recommended: square image, max 2 MB.","common.timezone.select":"Select timezone","common.timezone.search":"Search timezone...","common.timezone.empty":"No timezone found","common.date_picker.select":"Select date","common.date_range_picker.placeholder":"Pick a date range","common.date_range_picker.today":"Today","common.date_range_picker.yesterday":"Yesterday","common.date_range_picker.last_7_days":"Last 7 days","common.date_range_picker.last_30_days":"Last 30 days","common.date_range_picker.last_3_months":"Last 3 months","common.date_range_picker.last_6_months":"Last 6 months","common.date_range_picker.last_12_months":"Last 12 months","common.date_range_picker.this_month":"This month","common.date_range_picker.last_month":"Last month","common.date_range_picker.year_to_date":"Year to date","common.date_range_picker.last_year":"Last year","common.cancel":"Cancel","common.clear":"Clear","common.close":"Close","common.loading_more":"Loading more...","labels.title":"Labels","labels.description":"Create labels to organize and categorize your posts","labels.search":"Search labels...","labels.new_label":"New Label","labels.no_labels_yet":"No labels yet","labels.no_search_results":"No labels match your search","labels.try_different_search":"Try a different keyword or clear the search.","labels.create_first_label":"Create your first label","labels.table.name":"Name","labels.table.created_at":"Created","labels.actions.edit":"Edit label","labels.actions.delete":"Delete label","labels.create.title":"Create Label","labels.create.description":"Give your label a name and pick a color","labels.create.name":"Name","labels.create.name_placeholder":"Enter label name...","labels.create.color":"Color","labels.create.submit":"Create Label","labels.create.submitting":"Creating...","labels.edit.title":"Edit Label","labels.edit.description":"Update the name and color for this label","labels.edit.name":"Name","labels.edit.name_placeholder":"Enter label name...","labels.edit.color":"Color","labels.edit.submit":"Save Changes","labels.edit.submitting":"Saving...","labels.delete.title":"Delete Label","labels.delete.description":"Are you sure you want to delete this label? This action cannot be undone.","labels.delete.confirm":"Delete","labels.delete.cancel":"Cancel","labels.flash.created":"Label created successfully!","labels.flash.updated":"Label updated successfully!","labels.flash.deleted":"Label deleted successfully!","mail.mentioned.subject":":name mentioned you on TryPost","mail.mentioned.title":":name mentioned you","mail.mentioned.intro":":name mentioned you in a post comment.","mail.mentioned.cta":"View comment","mail.workspace_connections_disconnected.subject":"{1} :count account needs to be reconnected in :workspace|[2,*] :count accounts need to be reconnected in :workspace","mail.workspace_connections_disconnected.title":"Accounts Need Reconnection","mail.workspace_connections_disconnected.intro":"The following social accounts in your :workspace workspace have been disconnected and need to be reconnected:","mail.workspace_connections_disconnected.reasons_title":"This may have happened because:","mail.workspace_connections_disconnected.reason_expired":"Access tokens expired","mail.workspace_connections_disconnected.reason_revoked":"You revoked access to TryPost on the platform","mail.workspace_connections_disconnected.reason_changed":"The platform changed their authentication requirements","mail.workspace_connections_disconnected.reconnect_cta":"Please reconnect these accounts to continue scheduling and publishing posts.","mail.workspace_connections_disconnected.button":"Reconnect Accounts","notifications.post_ready.title":"Your post is ready","notifications.post_ready.body":"The AI just finished. Tap to review and publish.","notifications.account_disconnected.title":":platform account disconnected","notifications.account_disconnected.body":":account needs to be reconnected","notifications.account_token_expired.title":":platform account needs to be reconnected","notifications.account_token_expired.body":":account session expired — please reconnect to keep posting","posts.title":"Posts","posts.search":"Search posts...","posts.all_posts":"All Posts","posts.new_post":"New Post","posts.no_posts":"No posts found","posts.no_search_results":"No posts match your search","posts.try_different_search":"Try a different keyword or clear the search.","posts.start_creating":"Start by creating your first post.","posts.filter_by_label":"Filter by label","posts.label_search_placeholder":"Search labels...","posts.no_labels":"No labels found.","posts.clear_label_filter":"Clear label filter","posts.table.post":"Post","posts.table.status":"Status","posts.table.content":"Content","posts.table.platforms":"Platforms","posts.table.labels":"Labels","posts.table.scheduled_at":"Date","posts.table.actions":"","posts.manage_posts":"Manage all your posts","posts.delete_confirm":"Are you sure you want to delete this post?","posts.by":"by","posts.actions.view":"View post","posts.actions.delete":"Delete","posts.actions.duplicate":"Duplicate","posts.actions.copy_id":"Copy ID","posts.actions.copied":"ID copied to clipboard","posts.form.post_type":"Post Type","posts.form.board":"Board","posts.form.select_board":"Select a board","posts.form.search_board":"Search board...","posts.form.no_board_found":"No board found","posts.form.media":"Media","posts.form.min":"Min","posts.form.uploading":"Uploading...","posts.form.drop_to_upload":"Drop to upload","posts.form.drag_and_drop":"Drag & drop or click to upload","posts.form.photos_and_videos":"Photos and videos","posts.form.photos_only":"Photos only","posts.form.videos_only":"Videos only","posts.form.drag_to_reorder":"Drag to reorder","posts.form.caption":"Caption","posts.form.write_caption":"Write your caption...","posts.form.content_exceeds_platform":":platform: too long by :over chars (max :limit).","posts.form.tiktok.settings":"TikTok Settings","posts.form.tiktok.variant_label":"Post type","posts.form.tiktok.variant.video":"Video","posts.form.tiktok.variant.photo":"Photo carousel","posts.form.tiktok.posting_to":"Posting to","posts.form.tiktok.privacy_level":"Who can see this video?","posts.form.tiktok.privacy_placeholder":"Select visibility","posts.form.tiktok.privacy.public":"Public to everyone","posts.form.tiktok.privacy.friends":"Mutual follow friends","posts.form.tiktok.privacy.followers":"Followers","posts.form.tiktok.privacy.private":"Only me","posts.form.tiktok.privacy.private_disabled_branded":"Branded content visibility cannot be set to private.","posts.form.tiktok.privacy_hint":"The available options depend on your TikTok account settings.","posts.form.tiktok.auto_add_music":"Auto add music","posts.form.tiktok.auto_add_music_hint":"This feature is available only for photos. It will add a default music that you can change later.","posts.form.tiktok.yes":"Yes","posts.form.tiktok.no":"No","posts.form.tiktok.allow_users":"Allow users to:","posts.form.tiktok.comments":"Comment","posts.form.tiktok.duet":"Duet","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Video made with AI","posts.form.tiktok.disclose":"Disclose video content","posts.form.tiktok.disclose_hint":"Turn on to disclose that this video promotes goods or services in exchange for something of value. Your video could promote yourself, a third party, or both.","posts.form.tiktok.promotional_organic_title":"Your photo/video will be labeled as \"Promotional content\".","posts.form.tiktok.promotional_paid_title":"Your photo/video will be labeled as \"Paid partnership\".","posts.form.tiktok.promotional_description":"This cannot be changed once your video is posted.","posts.form.tiktok.compliance_incomplete":"You need to indicate if your content promotes yourself, a third party, or both.","posts.form.tiktok.privacy_required":"TikTok privacy level is required when publishing.","posts.form.tiktok.branded_cleared_private":"Privacy was cleared because Branded Content cannot be private.","posts.form.tiktok.interaction_disabled_by_creator":"Disabled by your TikTok account settings.","posts.form.tiktok.max_duration_exceeded":"Video is :duration s long but this account can only post videos up to :max s.","posts.form.tiktok.processing_hint":"After publishing, it may take a few minutes for the content to process and appear on your TikTok profile.","posts.form.tiktok.brand_organic":"Your brand","posts.form.tiktok.brand_organic_hint":"You are promoting yourself or your own brand. This video will be classified as Brand Organic.","posts.form.tiktok.brand_content":"Branded content","posts.form.tiktok.brand_content_hint":"You are promoting another brand or a third party. This video will be classified as Branded Content.","posts.form.tiktok.compliance.agree":"By posting, you agree to TikTok's","posts.form.tiktok.compliance.music_usage":"Music Usage Confirmation","posts.form.tiktok.compliance.and":"and","posts.form.tiktok.compliance.branded_policy":"Branded Content Policy","posts.form.instagram.settings":"Instagram Settings","posts.form.instagram.posting_to":"Posting to","posts.form.instagram.variant_label":"Post type","posts.form.instagram.variant.feed":"Feed Post","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Story","posts.form.instagram.aspect_label":"Aspect ratio","posts.form.instagram.aspect.square":"Square (1:1)","posts.form.instagram.aspect.portrait":"Portrait (4:5)","posts.form.instagram.aspect.landscape":"Landscape (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Facebook Settings","posts.form.facebook.posting_to":"Posting to","posts.form.facebook.variant_label":"Post type","posts.form.facebook.variant.post":"Post","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Story","posts.form.linkedin.settings":"LinkedIn Settings","posts.form.linkedin.settings_page":"LinkedIn Page Settings","posts.form.linkedin.posting_to":"Posting to","posts.form.linkedin.variant_label":"Post type","posts.form.linkedin.variant.post":"Post","posts.form.linkedin.variant.carousel":"Carousel","posts.form.pinterest.settings":"Pinterest Settings","posts.form.pinterest.posting_to":"Posting to","posts.form.pinterest.variant_label":"Pin type","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carousel","posts.form.pinterest.board":"Board","posts.form.pinterest.select_board":"Select a board","posts.form.pinterest.no_boards":"No Pinterest boards found. Create one in your Pinterest account first.","posts.form.pinterest.search_board":"Search boards...","posts.form.pinterest.no_board_found":"No board matches your search.","posts.form.pinterest.board_required":"Select a Pinterest board to publish this post.","posts.form.warnings.no_variant":"Pick a post type to continue.","posts.form.warnings.requires_media":"This post type requires at least one image or video.","posts.form.warnings.max_files_exceeded":"This post type accepts up to :max media files (you have :current).","posts.form.warnings.min_files_required":"This post type requires at least :min media files (you have :current).","posts.form.warnings.no_video_allowed":"This post type does not accept videos.","posts.form.warnings.no_image_allowed":"This post type accepts only videos.","posts.form.warnings.gif_not_allowed":"This platform does not accept GIF. Remove the GIF or choose a different network.","posts.form.warnings.image_too_large":"Image exceeds the :max limit for this post type (yours is :current).","posts.form.warnings.video_too_large":"Video exceeds the :max limit for this post type (yours is :current).","posts.form.warnings.video_too_long":"Video is :current long, but this post type allows up to :max.","posts.form.warnings.aspect_ratio_too_narrow":"Aspect ratio :current is too tall for this post type (min :min).","posts.form.warnings.aspect_ratio_too_wide":"Aspect ratio :current is too wide for this post type (max :max).","posts.status.pending":"Pending","posts.status.draft":"Draft","posts.status.scheduled":"Scheduled","posts.status.publishing":"Publishing","posts.status.published":"Published","posts.status.partially_published":"Partially Published","posts.status.failed":"Failed","posts.descriptions.draft":"Posts waiting to be scheduled","posts.descriptions.scheduled":"Posts scheduled for publishing","posts.descriptions.published":"Posts already published","posts.ai.generate.button_tooltip":"Generate with AI","posts.ai.generate.title":"Generate post with AI","posts.ai.generate.description":"Describe what the post should be about. The AI will use your brand context to write it.","posts.ai.generate.prompt_label":"What is this post about?","posts.ai.generate.prompt_placeholder":"e.g. Announce our new image-generation feature for carousels","posts.ai.generate.preview_label":"Preview","posts.ai.generate.start":"Generate","posts.ai.generate.apply":"Use this content","posts.ai.generate.retry":"Try again","posts.ai.generate.cancel":"Cancel","posts.ai.review.button_tooltip":"Review with AI","posts.ai.review.title":"Review post with AI","posts.ai.review.description":"AI scans for grammar, spelling, and clarity. Apply the suggestions you agree with.","posts.ai.review.loading":"Reviewing your text...","posts.ai.review.no_issues":"No issues found. Looks good.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Suggestion","posts.ai.review.apply":"Apply","posts.ai.review.apply_all":"Apply all","posts.ai.review.applied":"Applied","posts.ai.review.cancel":"Cancel","posts.show.title":"Post Details","posts.show.edit":"Edit","posts.show.back":"Back","posts.show.no_content":"No caption","posts.show.platforms":"Platforms","posts.show.no_platforms":"No platforms selected.","posts.show.view_on_platform":"View on platform","posts.show.published_on":"Published on :date","posts.show.scheduled_for":"Scheduled for :date","posts.show.draft":"Draft","posts.show.status_pending":"Pending","posts.show.metrics":"Metrics","posts.show.metrics_loading":"Loading metrics…","posts.show.metrics_unavailable":"Metrics unavailable for this platform yet.","posts.show.metrics_empty":"No metrics returned.","posts.edit.title":"Edit Post","posts.edit.view_title":"View Post","posts.edit.labels":"Labels","posts.edit.no_labels":"No labels created yet","posts.edit.schedule":"Schedule","posts.edit.pick_time":"Pick time","posts.edit.post_now":"Post now","posts.edit.time":"Time","posts.edit.cancel":"Cancel","posts.edit.delete":"Delete","posts.edit.schedule_for":"Schedule for","posts.edit.schedule_date":"Schedule date","posts.edit.unschedule":"Unschedule","posts.edit.saving":"Saving...","posts.edit.saved":"Saved","posts.edit.draft":"Draft","posts.edit.media":"Media","posts.edit.add_media":"Add media","posts.edit.caption":"Caption","posts.edit.caption_placeholder":"Write your caption...","posts.edit.compose_title":"Create a post","posts.edit.compose_subtitle":"Compose your message and add media","posts.edit.preview_empty.title":"No platform selected","posts.edit.preview_empty.description":"Select a platform to publish to see the preview.","posts.edit.drop_zone_title":"Add media","posts.edit.drop_zone_subtitle":"Drag & drop files or click to browse","posts.edit.add":"Add","posts.edit.publish_to":"Publish to","posts.edit.organize":"Organize","posts.edit.signatures":"Signatures","posts.edit.view_on_platform":"View on platform","posts.edit.platform_status":"Platform status","posts.edit.compliance_incomplete":"Some platform settings are incomplete or incompatible with the attached media.","posts.edit.compliance.requires_media":"Add an image or video to publish here.","posts.edit.compliance.too_many_files":"Only :max file(s) allowed for this format.","posts.edit.compliance.too_few_files":"Add at least :min files for this format.","posts.edit.compliance.no_videos":"Only images are allowed for this format.","posts.edit.compliance.no_images":"Only videos are allowed for this format.","posts.edit.compliance.no_gifs":"GIFs are not supported here.","posts.edit.compliance.video_too_large":"Video exceeds the size limit for this platform.","posts.edit.compliance.video_too_long":"Video must be under :seconds seconds for this format.","posts.edit.compliance.image_too_large":"Image exceeds the size limit for this platform.","posts.edit.compliance.aspect_ratio_invalid":"Aspect ratio is not supported by this format.","posts.edit.compliance.no_content_type":"Pick a content type for this platform.","posts.edit.publishing":"Publishing...","posts.edit.publishing_overlay_title":"Your post is being published","posts.edit.publishing_overlay_subtitle":"This can take a few moments. You can safely leave this page.","posts.edit.scheduled_overlay_title":"This post is scheduled","posts.edit.scheduled_overlay_subtitle":"Scheduled for :date. Unschedule it first to make changes.","posts.edit.unschedule_cta":"Unschedule to edit","posts.edit.tabs.preview":"Preview","posts.edit.tabs.schedule":"Schedule","posts.edit.tabs.comments":"Comments","posts.edit.tabs.comments_empty":"No comments yet.","posts.edit.media_picker.title":"Pick from gallery","posts.edit.media_picker.search":"Search media...","posts.edit.media_picker.empty":"No media in your gallery yet","posts.edit.media_picker.cancel":"Cancel","posts.edit.media_picker.add":"Add","posts.edit.media_picker.add_count":"Add :count","posts.edit.emoji_picker.search":"Search emoji","posts.edit.emoji_picker.empty":"No emojis found","posts.edit.emoji_picker.recent":"Frequently used","posts.edit.emoji_picker.smileys":"Smileys & emotion","posts.edit.emoji_picker.people":"People & body","posts.edit.emoji_picker.nature":"Animals & nature","posts.edit.emoji_picker.food":"Food & drink","posts.edit.emoji_picker.activities":"Activities","posts.edit.emoji_picker.travel":"Travel & places","posts.edit.emoji_picker.objects":"Objects","posts.edit.emoji_picker.symbols":"Symbols","posts.edit.emoji_picker.flags":"Flags","posts.edit.status.scheduled":"Scheduled","posts.edit.status.published":"Published","posts.edit.status.publishing":"Publishing...","posts.edit.status.failed":"Failed","posts.edit.delete_modal.title":"Delete Post","posts.edit.delete_modal.description":"Are you sure you want to delete this post? This action cannot be undone.","posts.edit.delete_modal.action":"Delete","posts.edit.delete_modal.cancel":"Cancel","posts.edit.sync_enable.title":"Enable sync?","posts.edit.sync_enable.description":"All platforms will share the same content. Any custom edits made to individual platforms will be replaced with the current content.","posts.edit.sync_enable.cancel":"Cancel","posts.edit.sync_enable.action":"Enable sync","posts.edit.sync_disable.title":"Disable sync?","posts.edit.sync_disable.description":"Each platform will keep its current content, but future edits will only apply to the platform you're editing.","posts.edit.sync_disable.customize_note":"You'll be able to customize the content for each platform individually.","posts.edit.sync_disable.cancel":"Cancel","posts.edit.sync_disable.action":"Disable sync","posts.edit.platforms_dialog.title":"Select Platforms","posts.edit.platforms_dialog.description":"Choose which platforms to publish this post to.","posts.edit.signatures_modal.search":"Search signatures...","posts.edit.signatures_modal.no_results":"No signatures found.","posts.edit.validation.select_board":"Select a board","posts.edit.validation.images_not_supported":"Images not supported","posts.edit.validation.videos_not_supported":"Videos not supported","posts.edit.validation.max_images":"Max :count images","posts.edit.validation.requires_media":"Requires media","posts.edit.validation.requires_content":"Text content is required","posts.edit.validation.exceeded":":count exceeded","posts.edit.validation.does_not_support_images":":platform does not support images","posts.edit.validation.supports_up_to_images":":platform supports up to :count images","posts.edit.validation.does_not_support_videos":":platform does not support videos","posts.content_types.instagram_feed.label":"Feed Post","posts.content_types.instagram_feed.description":"Appears in your feed and profile","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Short video up to 90 seconds","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Disappears after 24 hours","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Standard post with text and media","posts.content_types.linkedin_carousel.label":"Carousel","posts.content_types.linkedin_carousel.description":"Swipeable images","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Standard post with text and media","posts.content_types.linkedin_page_carousel.label":"Carousel","posts.content_types.linkedin_page_carousel.description":"Swipeable images","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Standard post on your page","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Short video up to 90 seconds","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Disappears after 24 hours","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Short-form video content","posts.content_types.tiktok_photo.label":"Photo carousel","posts.content_types.tiktok_photo.description":"Up to 35 photos as a swipeable carousel","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vertical video up to 60 seconds","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet with text and media","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Text post with optional media","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Standard image pin","posts.content_types.pinterest_video_pin.label":"Video Pin","posts.content_types.pinterest_video_pin.description":"Video pin (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carousel","posts.content_types.pinterest_carousel.description":"Multi-image carousel (2-5 images)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Text post with optional images","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Text post with optional media","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"LinkedIn Page","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Facebook Page","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post scheduled successfully!","posts.flash.deleted":"Post deleted successfully!","posts.flash.duplicated":"Post duplicated as a draft.","posts.flash.cannot_edit_published":"Published posts cannot be edited.","posts.flash.cannot_delete_published":"Published posts cannot be deleted.","posts.flash.connect_first":"Connect at least one social network before creating a post.","posts.errors.account_disconnected":"Social account is disconnected","posts.errors.account_inactive":"Social account is deactivated","posts.errors.account_token_expired":"Social account session expired — please reconnect","posts.delete.title":"Delete post?","posts.delete.description":"This action can't be undone. The post and all its media will be permanently removed.","posts.delete.confirm":"Yes, delete","posts.delete.cancel":"Cancel","posts.create.title":"Create a new post","posts.create.description":"Choose how you want to start.","posts.create.scratch_title":"Start from scratch","posts.create.scratch_description":"Open a blank post and write everything yourself.","posts.create.ai_title":"Generate with AI","posts.create.ai_description":"Describe what you want and AI generates the content for you.","posts.create.ai_configure_description":"Pick a format and describe the post you want to create.","posts.create.template_title":"Use a template","posts.create.template_description":"Pick from our curated templates and customize.","posts.create.coming_soon":"Coming soon","posts.create.preview.image_title":"Image title","posts.create.preview.image_body":"Image body","posts.create.steps.format_title":"Choose a format","posts.create.steps.format_description":"Select the type of post you want to create.","posts.create.steps.account_title":"Choose an account","posts.create.steps.account_description":"Select the social account to publish to.","posts.create.steps.media_title":"Media options","posts.create.steps.media_carousel":"How many slides?","posts.create.steps.media_optional":"Include images?","posts.create.steps.media_optional_label":"How many images?","posts.create.steps.media_none":"None","posts.create.steps.media_count_label":"Number of images","posts.create.steps.prompt_title":"Describe your post","posts.create.steps.prompt_label":"What is this post about?","posts.create.steps.prompt_placeholder":"e.g. Announce our new carousel feature for Instagram","posts.create.steps.preview_error":"Something went wrong. Please try again.","posts.create.steps.loading_page_title":"Generating your post","posts.create.steps.loading_eta":"Estimated time: about :minutes.","posts.create.steps.loading_eta_minute_one":"1 minute","posts.create.steps.loading_eta_minute_other":":count minutes","posts.create.steps.loading_leave_title":"You can keep working.","posts.create.steps.loading_leave_body":"We will notify you when the post is ready.","posts.create.steps.loading_leave_cta":"Go to calendar","posts.create.steps.loading_create_another_cta":"Create another post","posts.create.steps.loading_tip_credits":"Each AI image uses about 15 credits.","posts.create.steps.loading_tip_edit":"You will be able to edit everything once the post is ready.","posts.create.steps.loading_tip_draft":"Generated posts land in your drafts.","posts.create.steps.loading_tip_brand":"Tweak your brand settings to influence future posts.","posts.create.steps.loading_tip_carousel":"Carousels deliver one slide per uploaded image.","posts.create.steps.loading_tip_quality":"Image quality is set to balance speed and cost.","posts.create.steps.create":"Create post","posts.create.steps.back":"Back","posts.create.steps.next":"Continue","posts.create.steps.cancel":"Cancel","posts.create.steps.discard":"Discard","posts.create.steps.retry":"Try again","posts.create.steps.no_platforms":"No connected accounts","posts.create.steps.connect_first":"Connect at least one social account to use AI generation.","posts.create.steps.format.instagram_feed":"Instagram Feed Post","posts.create.steps.format.instagram_carousel":"Instagram Carousel","posts.create.steps.format.linkedin_post":"LinkedIn Post","posts.create.steps.format.linkedin_page_post":"LinkedIn Page Post","posts.create.steps.format.x_post":"X Post","posts.create.steps.format.bluesky_post":"Bluesky Post","posts.create.steps.format.threads_post":"Threads Post","posts.create.steps.format.mastodon_post":"Mastodon Post","posts.create.steps.format.facebook_post":"Facebook Post","posts.create.steps.format.pinterest_pin":"Pinterest Pin","posts.create.steps.format.instagram_story":"Instagram Story","posts.create.steps.format.facebook_story":"Facebook Story","posts.templates.browser_title":"Choose a template","posts.templates.browser_description":"Start from a curated template and adapt it.","posts.templates.search_placeholder":"Search templates…","posts.templates.no_search_results":"No templates match your search","posts.templates.try_different_search":"Try a different keyword or clear the search.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.all_platforms":"All platforms","posts.templates.platform_search_placeholder":"Search platform…","posts.templates.no_platform_match":"No platform matches.","posts.templates.use_this":"Use this template","posts.templates.no_templates":"No templates available.","posts.templates.applying":"Applying template…","posts.templates.category.product_launch":"Product launch","posts.templates.category.promotion":"Promotion","posts.templates.category.educational":"Educational","posts.templates.category.behind_the_scenes":"Behind the scenes","posts.templates.category.testimonial":"Testimonial","posts.templates.category.industry_tip":"Industry tip","posts.templates.category.event":"Event","posts.templates.category.engagement":"Engagement","settings.title":"Settings","settings.description":"Manage your profile and account settings","settings.hub.title":"Settings","settings.hub.description":"Choose what you want to manage.","settings.hub.profile.title":"Profile","settings.hub.profile.description":"Update your personal info, password, and notification preferences.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configure your workspace, brand, members, and API keys.","settings.hub.account.title":"Account","settings.hub.account.description":"Manage your account info, usage, and billing.","settings.nav.profile":"Profile","settings.nav.authentication":"Authentication","settings.nav.workspace":"Workspace","settings.nav.members":"Members","settings.nav.notifications":"Notifications","settings.nav.billing":"Billing","settings.notifications.title":"Notification preferences","settings.notifications.heading":"Email notifications","settings.notifications.description":"Choose which email notifications you want to receive","settings.notifications.post_published":"Post published","settings.notifications.post_published_description":"Receive an email when your post is published successfully","settings.notifications.post_failed":"Post failed","settings.notifications.post_failed_description":"Receive an email when your post fails to publish","settings.notifications.account_disconnected":"Account disconnected","settings.notifications.account_disconnected_description":"Receive an email when a social account is disconnected","settings.notifications.save":"Save preferences","settings.profile.title":"Profile settings","settings.profile.photo_heading":"Profile photo","settings.profile.photo_description":"Upload a profile photo","settings.profile.heading":"Profile information","settings.profile.description":"Update your name and email address","settings.profile.avatar":"Avatar","settings.profile.name":"Name","settings.profile.name_placeholder":"Full name","settings.profile.email":"Email address","settings.profile.email_placeholder":"Email address","settings.profile.email_unverified":"Your email address is unverified.","settings.profile.resend_verification":"Click here to resend the verification email.","settings.profile.verification_sent":"A new verification link has been sent to your email address.","settings.profile.save":"Save","settings.authentication.title":"Authentication","settings.authentication.page_title":"Authentication settings","settings.authentication.sessions.title":"Active sessions","settings.authentication.sessions.description":"If you notice anything suspicious, sign out of other devices.","settings.authentication.sessions.unknown_browser":"Unknown browser","settings.authentication.sessions.unknown_ip":"Unknown IP","settings.authentication.sessions.on":"on","settings.authentication.sessions.active_now":"Active now","settings.authentication.sessions.log_out_others":"Log out other devices","settings.authentication.sessions.modal_title":"Log out other devices","settings.authentication.sessions.modal_description_password":"Enter your current password to confirm you want to log out other browser sessions.","settings.authentication.sessions.modal_description_email":"Type your email address to confirm you want to log out other browser sessions.","settings.authentication.sessions.password_placeholder":"Current password","settings.authentication.sessions.email_placeholder":"Your account email","settings.authentication.sessions.cancel":"Cancel","settings.authentication.sessions.submit":"Log out other devices","settings.authentication.sessions.email_mismatch":"The email address does not match your account.","settings.authentication.sessions.flash_logged_out":"You have been logged out from other devices.","settings.authentication.password.update_title":"Update password","settings.authentication.password.set_title":"Set a password","settings.authentication.password.update_description":"Ensure your account is using a long, random password to stay secure.","settings.authentication.password.set_description":"Add a password so you can sign in without a connected provider.","settings.authentication.password.current_password":"Current password","settings.authentication.password.new_password":"New password","settings.authentication.password.confirm_password":"Confirm password","settings.authentication.password.save":"Save password","settings.authentication.password.set":"Set password","settings.authentication.providers.title":"Connected accounts","settings.authentication.providers.description":"Sign in faster with these connected providers.","settings.authentication.providers.connected":"Connected","settings.authentication.providers.not_connected":"Not connected","settings.authentication.providers.connect":"Connect","settings.authentication.providers.disconnect":"Disconnect","settings.authentication.providers.flash_disconnected":":provider disconnected successfully.","settings.authentication.providers.flash_connected":":provider connected successfully.","settings.authentication.providers.flash_already_linked":"That :provider account is already linked to another user.","settings.authentication.providers.flash_cannot_disconnect":"You cannot disconnect your only sign-in method. Set a password or connect another provider first.","settings.delete_account.heading":"Delete account","settings.delete_account.description":"Delete your account and all of its resources","settings.delete_account.warning":"Warning","settings.delete_account.warning_message":"Please proceed with caution, this cannot be undone.","settings.delete_account.button":"Delete account","settings.delete_account.modal_title":"Are you sure you want to delete your account?","settings.delete_account.modal_description_password":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm.","settings.delete_account.modal_description_email":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please type your email address :email to confirm.","settings.delete_account.password":"Password","settings.delete_account.password_placeholder":"Password","settings.delete_account.email_placeholder":"Your account email","settings.delete_account.email_mismatch":"The email address does not match your account.","settings.delete_account.cancel":"Cancel","settings.delete_account.confirm":"Delete account","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Brand","settings.workspace.tabs.users":"Members","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Workspace settings","settings.workspace.logo_heading":"Workspace logo","settings.workspace.logo_description":"Upload a logo for your workspace","settings.workspace.heading":"Workspace name","settings.workspace.description":"Update your workspace name","settings.workspace.members_heading":"Members","settings.workspace.members_description":"Manage workspace members and invitations","settings.workspace.name":"Name","settings.workspace.name_placeholder":"My Workspace","settings.workspace.save":"Save","settings.brand.title":"Brand","settings.brand.description":"Configure your brand identity for AI-generated content.","settings.brand.name":"Workspace name","settings.brand.name_placeholder":"My brand","settings.brand.website":"Website","settings.brand.website_placeholder":"https://yourbrand.com","settings.brand.brand_description":"Description","settings.brand.brand_description_placeholder":"Tell us about your brand, what you do, and who your audience is...","settings.brand.tone":"Tone of voice","settings.brand.tone_professional":"Professional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Friendly","settings.brand.tone_bold":"Bold","settings.brand.tone_inspirational":"Inspirational","settings.brand.tone_humorous":"Humorous","settings.brand.tone_educational":"Educational","settings.brand.voice_notes":"Voice notes","settings.brand.voice_notes_placeholder":"Additional writing guidelines, words to avoid, style preferences...","settings.brand.brand_color":"Brand color","settings.brand.background_color":"Background color","settings.brand.text_color":"Text color","settings.brand.font":"Font","settings.brand.image_style":"Image style","settings.brand.image_style_description":"Visual style applied when generating slide and cover images for AI posts.","settings.brand.image_style_cinematic":"Cinematic","settings.brand.image_style_illustration":"Illustration","settings.brand.image_style_isometric_3d":"Isometric","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Typographic","settings.brand.image_style_infographic":"Infographic","settings.brand.image_style_minimalist":"Minimalist","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Content language","settings.brand.content_language_description":"Language used for AI-generated captions, hashtags, and any text inside generated images or videos.","settings.members.title":"Members","settings.members.heading":"Team members","settings.members.description":"Manage members and invites for this workspace","settings.members.cancel":"Cancel","settings.members.remove":"Remove","settings.members.make_admin":"Make admin","settings.members.make_member":"Make member","settings.members.invite.title":"Invite Member","settings.members.invite.description":"Send an email invite to add collaborators","settings.members.invite.email":"Email","settings.members.invite.email_placeholder":"collaborator@email.com","settings.members.invite.role":"Role","settings.members.invite.role_placeholder":"Select a role","settings.members.invite.submit":"Send Invite","settings.members.pending.title":"Pending Invites","settings.members.pending.description":"Invites awaiting acceptance","settings.members.pending.empty":"No pending invites","settings.members.list.title":"Members","settings.members.list.description":"People with access to this workspace","settings.members.list.empty":"No members besides the owner","settings.members.remove_modal.title":"Remove member","settings.members.remove_modal.description":"Are you sure you want to remove this member from the workspace? They will lose access to all workspace resources.","settings.members.remove_modal.action":"Remove member","settings.members.cancel_invite_modal.title":"Cancel invitation","settings.members.cancel_invite_modal.description":"Are you sure you want to cancel this invitation?","settings.members.cancel_invite_modal.action":"Cancel invitation","settings.members.roles.owner":"Owner","settings.members.roles.admin":"Admin","settings.members.roles.member":"Member","settings.members.roles.viewer":"Viewer","settings.members.flash.invite_sent":"Invite sent successfully!","settings.members.flash.invite_deleted":"Invite deleted.","settings.members.flash.member_removed":"Member removed successfully.","settings.members.flash.role_updated":"Member role updated.","settings.members.flash.wrong_email":"This invite is for a different email address.","settings.members.flash.already_member":"You are already a member of this workspace.","settings.members.flash.invite_accepted":"Welcome! You are now a member of the workspace.","settings.members.flash.invite_declined":"Invite declined.","settings.account.tabs.account":"Account","settings.account.tabs.usage":"Usage","settings.account.tabs.billing":"Billing","settings.account.title":"Account Settings","settings.account.description":"Manage your account name and billing email","settings.account.name":"Account Name","settings.account.name_placeholder":"My Company","settings.account.billing_email":"Billing Email","settings.account.billing_email_placeholder":"billing@company.com","settings.account.billing_email_hint":"This email will be used for invoices and billing communications from Stripe.","settings.account.submit":"Save","settings.flash.account_updated":"Account updated successfully!","settings.flash.profile_updated":"Profile updated successfully!","settings.flash.language_updated":"Language updated successfully!","settings.flash.password_updated":"Password updated successfully!","settings.flash.workspace_updated":"Settings updated successfully!","settings.flash.photo_updated":"Photo updated successfully!","settings.flash.photo_deleted":"Photo removed successfully!","settings.flash.logo_updated":"Logo uploaded successfully!","settings.flash.logo_deleted":"Logo removed successfully!","settings.flash.notifications_updated":"Notification preferences updated!","settings.api_keys.title":"API Keys","settings.api_keys.page_title":"API Keys","settings.api_keys.heading":"API Keys","settings.api_keys.description":"Manage API keys for programmatic access to your workspace.","settings.api_keys.create":"Create API Key","settings.api_keys.copy":"Copy","settings.api_keys.new_token_message":"Your new API key has been created. Copy it now — you won't be able to see it again.","settings.api_keys.table.name":"Name","settings.api_keys.table.key":"Key","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expires","settings.api_keys.table.last_used":"Last Used","settings.api_keys.table.never":"Never","settings.api_keys.actions.copy_id":"Copy API Key ID","settings.api_keys.actions.copy_id_success":"API Key ID copied to clipboard","settings.api_keys.actions.delete":"Delete","settings.api_keys.empty.title":"No API keys yet","settings.api_keys.empty.description":"Create an API key to access your workspace programmatically.","settings.api_keys.delete_modal.title":"Delete API key","settings.api_keys.delete_modal.description":"Are you sure you want to delete this API key? Any applications using this key will lose access immediately.","settings.api_keys.delete_modal.action":"Delete API key","settings.api_keys.create_dialog.title":"Create API Key","settings.api_keys.create_dialog.description":"Create a new API key for programmatic access to your workspace.","settings.api_keys.create_dialog.name":"Name","settings.api_keys.create_dialog.name_placeholder":"e.g. Production API Key","settings.api_keys.create_dialog.expires":"Expiration date (optional)","settings.api_keys.create_dialog.expires_placeholder":"No expiration","settings.api_keys.create_dialog.submit":"Create","settings.api_keys.create_dialog.cancel":"Cancel","settings.api_keys.flash.created":"API key created successfully!","settings.api_keys.flash.deleted":"API key deleted successfully!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Select workspace","sidebar.create_workspace":"Create workspace","sidebar.create_post":"Create post","sidebar.profile":"Profile","sidebar.log_out":"Log out","sidebar.workspace.connections":"Connections","sidebar.workspace.signatures":"Signatures","sidebar.workspace.labels":"Labels","sidebar.workspace.assets":"Assets","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Select","sidebar.theme":"Theme: :name","sidebar.theme_light":"Light","sidebar.theme_dark":"Dark","sidebar.theme_system":"System","sidebar.language":"Language: :name","sidebar.language_select":"Language: Select","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Support","sidebar.analytics":"Analytics","sidebar.settings":"Settings","sidebar.posts.calendar":"Calendar","sidebar.posts.all":"All","sidebar.posts.scheduled":"Scheduled","sidebar.posts.posted":"Posted","sidebar.posts.drafts":"Drafts","sidebar.notifications":"Notifications","sidebar.mark_all_read":"Mark all as read","sidebar.mark_as_read":"Mark as read","sidebar.archive_all":"Archive all","sidebar.no_notifications":"No notifications","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Share feedback","sidebar.support.last_updates":"Last Updates","sidebar.support.docs":"Documentation","signatures.title":"Signatures","signatures.description":"Create reusable signatures to quickly append to your posts","signatures.search":"Search signatures...","signatures.new":"New signature","signatures.empty_title":"No signatures yet","signatures.empty_description":"Create signatures to quickly append hashtags, links, or any reusable text to your posts","signatures.no_search_results":"No signatures match your search","signatures.try_different_search":"Try a different keyword or clear the search.","signatures.table.name":"Name","signatures.table.content":"Content","signatures.table.created_at":"Created","signatures.actions.edit":"Edit signature","signatures.actions.delete":"Delete signature","signatures.create.title":"Create signature","signatures.create.description":"Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).","signatures.create.name":"Name","signatures.create.name_placeholder":"e.g. Marketing, Travel, Brand sign-off","signatures.create.content":"Content","signatures.create.content_placeholder":"#marketing #socialmedia\nLearn more: https://yourbrand.com","signatures.create.content_hint":"Hashtags, links, custom intros, signoffs — anything you append to posts.","signatures.create.submit":"Create signature","signatures.create.submitting":"Creating...","signatures.edit.title":"Edit signature","signatures.edit.description":"Update the name and content for this signature.","signatures.edit.name":"Name","signatures.edit.name_placeholder":"e.g. Marketing, Travel, Brand sign-off","signatures.edit.content":"Content","signatures.edit.content_placeholder":"#marketing #socialmedia\nLearn more: https://yourbrand.com","signatures.edit.content_hint":"Hashtags, links, custom intros, signoffs — anything you append to posts.","signatures.edit.submit":"Save changes","signatures.edit.submitting":"Saving...","signatures.delete.title":"Delete signature","signatures.delete.description":"Are you sure you want to delete this signature? This action cannot be undone.","signatures.delete.confirm":"Delete","signatures.delete.cancel":"Cancel","signatures.flash.created":"Signature created.","signatures.flash.updated":"Signature updated.","signatures.flash.deleted":"Signature deleted.","usage.title":"Usage","usage.section_account":"Account","usage.section_account_description":"Quotas and limits for your :plan plan.","usage.section_ai":"AI Credits","usage.section_ai_description":"Credits are debited as AI features are used. They reset on the first of every month.","usage.workspaces":"Workspaces","usage.social_accounts":"Social Accounts","usage.members":"Members","usage.credits":"Credits","workspaces.title":"Workspaces","workspaces.select_title":"Your workspaces","workspaces.select_description":"Select a workspace to continue","workspaces.current":"Current","workspaces.connections":":count connections","workspaces.posts":":count posts","workspaces.create.page_title":"Create your workspace","workspaces.create.title":"Set up your workspace","workspaces.create.description":"Tell us a bit about you or your project. We'll use it to tailor AI-generated posts to your voice.","workspaces.create.website":"Website","workspaces.create.website_placeholder":"https://yourbrand.com","workspaces.create.autofill":"Autofill from website","workspaces.create.autofill_missing_url":"Enter a URL first.","workspaces.create.autofill_success":"Brand info loaded.","workspaces.create.autofill_error":"Could not autofill. You can fill the fields manually.","workspaces.create.autofill_errors.unreachable":"We could not reach that website (:reason).","workspaces.create.autofill_errors.http_status":"The website returned an unexpected status (:status).","workspaces.create.autofill_errors.invalid_scheme":"Only http and https URLs are supported.","workspaces.create.autofill_errors.missing_host":"The URL is missing a host.","workspaces.create.autofill_errors.unresolvable_host":"We could not resolve the host (:host).","workspaces.create.autofill_errors.private_network":"URLs pointing to private networks are not allowed.","workspaces.create.logo_captured":"Logo captured from your website.","workspaces.create.name":"Workspace name","workspaces.create.name_placeholder":"e.g. Acme Inc","workspaces.create.brand_description":"Brand description","workspaces.create.brand_description_placeholder":"What does your brand do?","workspaces.create.tone":"Brand tone","workspaces.create.tone_professional":"Professional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Friendly","workspaces.create.tone_bold":"Bold","workspaces.create.tone_inspirational":"Inspirational","workspaces.create.tone_humorous":"Humorous","workspaces.create.tone_educational":"Educational","workspaces.create.content_language":"Content language","workspaces.create.content_language_description":"AI-generated captions will be written in this language.","workspaces.create.voice_notes":"Voice notes (optional)","workspaces.create.voice_notes_placeholder":"e.g. short, punchy sentences. avoid jargon.","workspaces.create.brand_color":"Brand color","workspaces.create.background_color":"Background color","workspaces.create.text_color":"Text color","workspaces.create.submit":"Create workspace","workspaces.create.success":"Workspace created. Connect a social account to start posting.","workspaces.limit_reached":"You have reached your plan limit for workspaces.","workspaces.flash.deleted":"Workspace deleted successfully."} \ No newline at end of file +{"auth.failed":"These credentials do not match our records.","auth.password":"The provided password is incorrect.","auth.throttle":"Too many login attempts. Please try again in :seconds seconds.","pagination.previous":"« Previous","pagination.next":"Next »","passwords.reset":"Your password has been reset.","passwords.sent":"We have emailed your password reset link.","passwords.throttled":"Please wait before retrying.","passwords.token":"This password reset token is invalid.","passwords.user":"We can't find a user with that email address.","validation.accepted":"The :attribute field must be accepted.","validation.accepted_if":"The :attribute field must be accepted when :other is :value.","validation.active_url":"The :attribute field must be a valid URL.","validation.after":"The :attribute field must be a date after :date.","validation.after_or_equal":"The :attribute field must be a date after or equal to :date.","validation.alpha":"The :attribute field must only contain letters.","validation.alpha_dash":"The :attribute field must only contain letters, numbers, dashes, and underscores.","validation.alpha_num":"The :attribute field must only contain letters and numbers.","validation.any_of":"The :attribute field is invalid.","validation.array":"The :attribute field must be an array.","validation.ascii":"The :attribute field must only contain single-byte alphanumeric characters and symbols.","validation.before":"The :attribute field must be a date before :date.","validation.before_or_equal":"The :attribute field must be a date before or equal to :date.","validation.between.array":"The :attribute field must have between :min and :max items.","validation.between.file":"The :attribute field must be between :min and :max kilobytes.","validation.between.numeric":"The :attribute field must be between :min and :max.","validation.between.string":"The :attribute field must be between :min and :max characters.","validation.boolean":"The :attribute field must be true or false.","validation.can":"The :attribute field contains an unauthorized value.","validation.confirmed":"The :attribute field confirmation does not match.","validation.contains":"The :attribute field is missing a required value.","validation.current_password":"The password is incorrect.","validation.date":"The :attribute field must be a valid date.","validation.date_equals":"The :attribute field must be a date equal to :date.","validation.date_format":"The :attribute field must match the format :format.","validation.decimal":"The :attribute field must have :decimal decimal places.","validation.declined":"The :attribute field must be declined.","validation.declined_if":"The :attribute field must be declined when :other is :value.","validation.different":"The :attribute field and :other must be different.","validation.digits":"The :attribute field must be :digits digits.","validation.digits_between":"The :attribute field must be between :min and :max digits.","validation.dimensions":"The :attribute field has invalid image dimensions.","validation.distinct":"The :attribute field has a duplicate value.","validation.doesnt_contain":"The :attribute field must not contain any of the following: :values.","validation.doesnt_end_with":"The :attribute field must not end with one of the following: :values.","validation.doesnt_start_with":"The :attribute field must not start with one of the following: :values.","validation.email":"The :attribute field must be a valid email address.","validation.encoding":"The :attribute field must be encoded in :encoding.","validation.ends_with":"The :attribute field must end with one of the following: :values.","validation.enum":"The selected :attribute is invalid.","validation.exists":"The selected :attribute is invalid.","validation.extensions":"The :attribute field must have one of the following extensions: :values.","validation.file":"The :attribute field must be a file.","validation.filled":"The :attribute field must have a value.","validation.gt.array":"The :attribute field must have more than :value items.","validation.gt.file":"The :attribute field must be greater than :value kilobytes.","validation.gt.numeric":"The :attribute field must be greater than :value.","validation.gt.string":"The :attribute field must be greater than :value characters.","validation.gte.array":"The :attribute field must have :value items or more.","validation.gte.file":"The :attribute field must be greater than or equal to :value kilobytes.","validation.gte.numeric":"The :attribute field must be greater than or equal to :value.","validation.gte.string":"The :attribute field must be greater than or equal to :value characters.","validation.hex_color":"The :attribute field must be a valid hexadecimal color.","validation.image":"The :attribute field must be an image.","validation.in":"The selected :attribute is invalid.","validation.in_array":"The :attribute field must exist in :other.","validation.in_array_keys":"The :attribute field must contain at least one of the following keys: :values.","validation.integer":"The :attribute field must be an integer.","validation.ip":"The :attribute field must be a valid IP address.","validation.ipv4":"The :attribute field must be a valid IPv4 address.","validation.ipv6":"The :attribute field must be a valid IPv6 address.","validation.json":"The :attribute field must be a valid JSON string.","validation.list":"The :attribute field must be a list.","validation.lowercase":"The :attribute field must be lowercase.","validation.lt.array":"The :attribute field must have less than :value items.","validation.lt.file":"The :attribute field must be less than :value kilobytes.","validation.lt.numeric":"The :attribute field must be less than :value.","validation.lt.string":"The :attribute field must be less than :value characters.","validation.lte.array":"The :attribute field must not have more than :value items.","validation.lte.file":"The :attribute field must be less than or equal to :value kilobytes.","validation.lte.numeric":"The :attribute field must be less than or equal to :value.","validation.lte.string":"The :attribute field must be less than or equal to :value characters.","validation.mac_address":"The :attribute field must be a valid MAC address.","validation.max.array":"The :attribute field must not have more than :max items.","validation.max.file":"The :attribute field must not be greater than :max kilobytes.","validation.max.numeric":"The :attribute field must not be greater than :max.","validation.max.string":"The :attribute field must not be greater than :max characters.","validation.max_digits":"The :attribute field must not have more than :max digits.","validation.mimes":"The :attribute field must be a file of type: :values.","validation.mimetypes":"The :attribute field must be a file of type: :values.","validation.min.array":"The :attribute field must have at least :min items.","validation.min.file":"The :attribute field must be at least :min kilobytes.","validation.min.numeric":"The :attribute field must be at least :min.","validation.min.string":"The :attribute field must be at least :min characters.","validation.min_digits":"The :attribute field must have at least :min digits.","validation.missing":"The :attribute field must be missing.","validation.missing_if":"The :attribute field must be missing when :other is :value.","validation.missing_unless":"The :attribute field must be missing unless :other is :value.","validation.missing_with":"The :attribute field must be missing when :values is present.","validation.missing_with_all":"The :attribute field must be missing when :values are present.","validation.multiple_of":"The :attribute field must be a multiple of :value.","validation.not_in":"The selected :attribute is invalid.","validation.not_regex":"The :attribute field format is invalid.","validation.numeric":"The :attribute field must be a number.","validation.password.letters":"The :attribute field must contain at least one letter.","validation.password.mixed":"The :attribute field must contain at least one uppercase and one lowercase letter.","validation.password.numbers":"The :attribute field must contain at least one number.","validation.password.symbols":"The :attribute field must contain at least one symbol.","validation.password.uncompromised":"The given :attribute has appeared in a data leak. Please choose a different :attribute.","validation.present":"The :attribute field must be present.","validation.present_if":"The :attribute field must be present when :other is :value.","validation.present_unless":"The :attribute field must be present unless :other is :value.","validation.present_with":"The :attribute field must be present when :values is present.","validation.present_with_all":"The :attribute field must be present when :values are present.","validation.prohibited":"The :attribute field is prohibited.","validation.prohibited_if":"The :attribute field is prohibited when :other is :value.","validation.prohibited_if_accepted":"The :attribute field is prohibited when :other is accepted.","validation.prohibited_if_declined":"The :attribute field is prohibited when :other is declined.","validation.prohibited_unless":"The :attribute field is prohibited unless :other is in :values.","validation.prohibits":"The :attribute field prohibits :other from being present.","validation.regex":"The :attribute field format is invalid.","validation.required":"The :attribute field is required.","validation.required_array_keys":"The :attribute field must contain entries for: :values.","validation.required_if":"The :attribute field is required when :other is :value.","validation.required_if_accepted":"The :attribute field is required when :other is accepted.","validation.required_if_declined":"The :attribute field is required when :other is declined.","validation.required_unless":"The :attribute field is required unless :other is in :values.","validation.required_with":"The :attribute field is required when :values is present.","validation.required_with_all":"The :attribute field is required when :values are present.","validation.required_without":"The :attribute field is required when :values is not present.","validation.required_without_all":"The :attribute field is required when none of :values are present.","validation.same":"The :attribute field must match :other.","validation.size.array":"The :attribute field must contain :size items.","validation.size.file":"The :attribute field must be :size kilobytes.","validation.size.numeric":"The :attribute field must be :size.","validation.size.string":"The :attribute field must be :size characters.","validation.starts_with":"The :attribute field must start with one of the following: :values.","validation.string":"The :attribute field must be a string.","validation.timezone":"The :attribute field must be a valid timezone.","validation.unique":"The :attribute has already been taken.","validation.uploaded":"The :attribute failed to upload.","validation.uppercase":"The :attribute field must be uppercase.","validation.url":"The :attribute field must be a valid URL.","validation.ulid":"The :attribute field must be a valid ULID.","validation.uuid":"The :attribute field must be a valid UUID.","validation.custom.attribute-name.rule-name":"custom-message","accounts.title":"Connections","accounts.page_title":"Social Accounts","accounts.description":"Overview of all your connected social accounts","accounts.add_social":"Add Social","accounts.add_social_title":"Connect a Social Account","accounts.add_social_description":"Connect a social account to TryPost to start posting","accounts.connect_cta":"Connect","accounts.no_accounts":"No accounts connected yet","accounts.no_accounts_description":"Connect your social networks to start scheduling and publishing posts","accounts.no_search_results":"No accounts match your search","accounts.try_different_search":"Try a different keyword or clear the search.","accounts.search":"Search accounts...","accounts.added":"Added :date","accounts.limit_reached":"You have reached your plan limit for social accounts.","accounts.not_connected":"Not connected","accounts.connect":"Connect","accounts.connection_lost":"Connection lost","accounts.reconnect_account":"Reconnect account","accounts.view_profile":"View profile","accounts.disconnect":"Disconnect","accounts.table.account":"Account","accounts.table.platform":"Platform","accounts.table.status":"Status","accounts.table.last_used":"Last used","accounts.table.added":"Added","accounts.table.active":"Active","accounts.never_used":"Never used","accounts.status.connected":"Connected","accounts.status.disconnected":"Disconnected","accounts.descriptions.linkedin":"Connect your LinkedIn personal profile","accounts.descriptions.linkedin-page":"Connect a LinkedIn company page","accounts.descriptions.x":"Connect your X (Twitter) account","accounts.descriptions.tiktok":"Connect your TikTok account","accounts.descriptions.youtube":"Connect a YouTube channel","accounts.descriptions.facebook":"Connect a Facebook page","accounts.descriptions.instagram":"Connect an Instagram professional account","accounts.descriptions.instagram-facebook":"Connect Instagram via Facebook page","accounts.descriptions.threads":"Connect your Threads account","accounts.descriptions.pinterest":"Connect your Pinterest account","accounts.descriptions.bluesky":"Connect your Bluesky account","accounts.descriptions.mastodon":"Connect your Mastodon account","accounts.disconnect_modal.title":"Disconnect Account","accounts.disconnect_modal.description":"Are you sure you want to disconnect this account? You can reconnect it at any time.","accounts.disconnect_modal.confirm":"Disconnect","accounts.disconnect_modal.cancel":"Cancel","accounts.bluesky.title":"Connect Bluesky","accounts.bluesky.description":"Enter your credentials to connect","accounts.bluesky.email":"Email","accounts.bluesky.email_placeholder":"yourhandle.bsky.social","accounts.bluesky.app_password":"App Password","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use an App Password for security. Create one at bsky.app/settings.","accounts.bluesky.submit":"Connect Bluesky","accounts.bluesky.submitting":"Connecting...","accounts.mastodon.title":"Connect Mastodon","accounts.mastodon.description":"Enter your Mastodon instance","accounts.mastodon.instance_url":"Instance URL","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)","accounts.mastodon.submit":"Continue with Mastodon","accounts.mastodon.submitting":"Connecting...","accounts.facebook.title":"Select Facebook Page","accounts.facebook.description":"Choose which page you want to connect","accounts.facebook.no_pages":"No pages found","accounts.facebook.no_pages_description":"You are not an admin of any Facebook page.","accounts.facebook.page_label":"Facebook Page","accounts.instagram_facebook.title":"Select Instagram Account","accounts.instagram_facebook.description":"Choose which Instagram account you want to connect","accounts.instagram_facebook.no_pages":"No Instagram accounts found","accounts.instagram_facebook.no_pages_description":"No Facebook Pages with linked Instagram Business accounts were found.","accounts.linkedin.title":"Select LinkedIn Page","accounts.linkedin.description":"Choose which page you want to connect","accounts.linkedin.no_pages":"No pages found","accounts.linkedin.no_pages_description":"You are not an administrator of any LinkedIn page.","accounts.linkedin.page_label":"LinkedIn Page","accounts.flash.disconnected":"Account disconnected successfully!","accounts.flash.connected":"Account connected successfully!","accounts.flash.session_expired":"Session expired. Please try again.","accounts.flash.workspace_not_found":"Workspace not found.","accounts.flash.activated":"Account activated!","accounts.flash.deactivated":"Account deactivated!","accounts.flash.already_connected":"This platform is already connected.","accounts.flash.no_youtube_channels":"No YouTube channels found. Please create a channel first.","accounts.popup_callback.title_success":"Connected","accounts.popup_callback.title_error":"Error","accounts.popup_callback.closing":"This window will close automatically...","accounts.popup_callback.close_now":"You can close this window now.","accounts.popup_callback.connected":"Account connected!","accounts.popup_callback.reconnected":"Account reconnected!","accounts.popup_callback.error_connecting":"Error connecting account. Please try again.","accounts.popup_callback.error_connecting_page":"Error connecting page. Please try again.","accounts.popup_callback.error_connecting_channel":"Error connecting channel. Please try again.","accounts.popup_callback.session_expired":"Session expired. Please try again.","accounts.popup_callback.workspace_not_found":"Workspace not found.","accounts.popup_callback.invalid_state":"Invalid state. Please try again.","accounts.popup_callback.failed_to_authenticate":"Failed to authenticate.","accounts.popup_callback.failed_to_get_profile":"Failed to get profile.","accounts.popup_callback.page_not_found":"Page not found.","accounts.popup_callback.channel_not_found":"Channel not found.","accounts.popup_callback.no_facebook_pages":"No Facebook Pages found. You need to be an admin of at least one page.","accounts.popup_callback.no_facebook_instagram_pages":"No Facebook Pages with linked Instagram accounts found.","accounts.popup_callback.no_youtube_channels":"No YouTube channels found. Please create a channel first.","accounts.popup_callback.not_linkedin_admin":"You are not an administrator of any LinkedIn page.","analytics.no_accounts":"No connected accounts with analytics.","analytics.no_accounts_match":"No accounts match.","analytics.search_account":"Search account…","analytics.select_account":"Select an account to view analytics.","analytics.no_data":"No analytics data available.","analytics.metrics.avg_view_duration":"Avg. View Duration (s)","analytics.metrics.avg_view_percentage":"Avg. View Percentage","analytics.metrics.bookmarks":"Bookmarks","analytics.metrics.clicks":"Clicks","analytics.metrics.comments":"Comments","analytics.metrics.engagement":"Engagement","analytics.metrics.favourites":"Favourites","analytics.metrics.followers":"Followers","analytics.metrics.following":"Following","analytics.metrics.impressions":"Impressions","analytics.metrics.interactions":"Interactions","analytics.metrics.likes":"Likes","analytics.metrics.minutes_watched":"Minutes Watched","analytics.metrics.organic_followers":"Organic Followers","analytics.metrics.outbound_clicks":"Outbound Clicks","analytics.metrics.page_followers":"Page Followers","analytics.metrics.page_reach":"Page Reach","analytics.metrics.page_views":"Page Views","analytics.metrics.paid_followers":"Paid Followers","analytics.metrics.pin_click_rate":"Pin Click Rate","analytics.metrics.pin_clicks":"Pin Clicks","analytics.metrics.posts_engagement":"Posts Engagement","analytics.metrics.posts_reach":"Posts Reach","analytics.metrics.quotes":"Quotes","analytics.metrics.reach":"Reach","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Recent Comments","analytics.metrics.recent_likes":"Recent Likes","analytics.metrics.recent_shares":"Recent Shares","analytics.metrics.replies":"Replies","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Saves","analytics.metrics.shares":"Shares","analytics.metrics.subscribers_gained":"Subscribers Gained","analytics.metrics.subscribers_lost":"Subscribers Lost","analytics.metrics.total_likes":"Total Likes","analytics.metrics.video_views":"Video Views","analytics.metrics.videos":"Videos","analytics.metrics.views":"Views","assets.title":"Assets","assets.tabs.my_uploads":"My Uploads","assets.tabs.stock_photos":"Stock Photos","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Drag & drop your files here, or click to select","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Uploading...","assets.empty.title":"No assets yet","assets.empty.description":"Upload images and videos to build your media library.","assets.save_to_assets":"Save to Assets","assets.saved":"Saved to your assets!","assets.create_post":"Create post","assets.add_to_post":"Add to post","assets.search_placeholder":"Search media...","assets.delete.title":"Delete asset","assets.delete.description":"Are you sure you want to delete this asset? This action cannot be undone.","assets.delete.confirm":"Delete","assets.delete.cancel":"Cancel","assets.unsplash.search_placeholder":"Search free photos...","assets.unsplash.no_results":"No photos found","assets.unsplash.no_results_description":"Try a different search term.","assets.unsplash.trending":"Trending on Unsplash","assets.unsplash.start_searching":"Search for free stock photos from Unsplash","assets.giphy.trending":"Trending on Giphy","assets.giphy.search_placeholder":"Search GIFs...","assets.giphy.no_results":"No GIFs found","assets.giphy.no_results_description":"Try a different search term.","assets.giphy.powered_by":"Powered by GIPHY","auth.flash.welcome":"Welcome to TryPost!","auth.flash.welcome_trial":"Welcome to TryPost! Your trial has started.","auth.legal":"By continuing, you agree to our Terms of Service and Privacy Policy.","auth.slides.calendar.title":"Visual Calendar","auth.slides.calendar.description":"Plan and schedule your content with an intuitive drag-and-drop calendar across all your social accounts.","auth.slides.scheduling.title":"Smart Scheduling","auth.slides.scheduling.description":"Schedule posts across LinkedIn, X, Instagram, TikTok, YouTube, and more — all from one place.","auth.slides.media.title":"Rich Media","auth.slides.media.description":"Publish images, carousels, stories, and reels. Each platform gets the right format automatically.","auth.slides.video.title":"Video Publishing","auth.slides.video.description":"Upload videos once and publish to TikTok, YouTube Shorts, Instagram Reels, and Facebook Reels.","auth.slides.team.title":"Team Workspaces","auth.slides.team.description":"Invite your team, assign roles, and manage multiple brands from separate workspaces.","auth.slides.signatures.title":"Signatures","auth.slides.signatures.description":"Save reusable signatures (hashtags, links, signoffs) and append them to posts with one click.","auth.or_continue_with":"Or continue with","auth.google_login":"Log in with Google","auth.google_signup":"Sign up with Google","auth.github_login":"Log in with GitHub","auth.github_signup":"Sign up with GitHub","auth.github_email_unavailable":"Unable to retrieve your email from GitHub. Make your GitHub email public or grant the email scope, then try again.","auth.signup_success.page_title":"Welcome","auth.signup_success.title":"Setting up your account","auth.signup_success.description":"This usually takes just a few seconds...","auth.login.title":"Log in to your account","auth.login.description":"Enter your email and password below to log in","auth.login.page_title":"Log in","auth.login.email":"Email address","auth.login.password":"Password","auth.login.forgot_password":"Forgot password?","auth.login.remember_me":"Remember me","auth.login.submit":"Log in","auth.login.no_account":"Don't have an account?","auth.login.sign_up":"Sign up","auth.register.title":"Create an account","auth.register.description":"Enter your details below to create your account","auth.register.page_title":"Register","auth.register.name":"Name","auth.register.name_placeholder":"Full name","auth.register.email":"Email address","auth.register.password":"Password","auth.register.show_password":"Show password","auth.register.hide_password":"Hide password","auth.register.submit":"Create account","auth.register.has_account":"Already have an account?","auth.register.log_in":"Log in","auth.forgot_password.title":"Forgot password","auth.forgot_password.description":"Enter your email to receive a password reset link","auth.forgot_password.page_title":"Forgot password","auth.forgot_password.email":"Email address","auth.forgot_password.submit":"Email password reset link","auth.forgot_password.return_to":"Or, return to","auth.forgot_password.log_in":"log in","auth.reset_password.title":"Reset password","auth.reset_password.description":"Please enter your new password below","auth.reset_password.page_title":"Reset password","auth.reset_password.email":"Email","auth.reset_password.password":"Password","auth.reset_password.confirm_password":"Confirm Password","auth.reset_password.confirm_placeholder":"Confirm password","auth.reset_password.submit":"Reset password","auth.verify_email.title":"Verify email","auth.verify_email.description":"Please verify your email address by clicking on the link we just emailed to you.","auth.verify_email.page_title":"Email verification","auth.verify_email.link_sent":"A new verification link has been sent to the email address you provided during registration.","auth.verify_email.resend":"Resend verification email","auth.verify_email.log_out":"Log out","auth.accept_invite.page_title":"Accept Invite","auth.accept_invite.title":"You've been invited!","auth.accept_invite.description":"You've been invited to join the :workspace workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Your role","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Accept Invite","auth.accept_invite.decline":"Decline Invite","auth.accept_invite.login_prompt":"Log in or create an account to accept this invite.","auth.accept_invite.log_in":"Log in","auth.accept_invite.create_account":"Create Account","billing.title":"Billing","billing.upgrade_dialog.title":"Upgrade your plan","billing.upgrade_dialog.description":"Pick a plan that fits your needs.","billing.upgrade_dialog.current_plan":"Current plan","billing.upgrade_dialog.current_short":"Current","billing.upgrade_dialog.current_badge":"Current","billing.upgrade_dialog.subscribe":"Subscribe","billing.upgrade_dialog.switch":"Switch to this plan","billing.upgrade_dialog.switch_short":"Switch","billing.upgrade_dialog.switch_to_yearly":"Switch to yearly","billing.upgrade_dialog.switch_to_monthly":"Switch to monthly","billing.upgrade_dialog.unavailable":"Unavailable","billing.upgrade_dialog.reasons.workspace_limit":"You've reached the workspace limit on your current plan. Upgrade to create more workspaces.","billing.upgrade_dialog.reasons.social_account_limit":"You've reached the social account limit on your current plan. Upgrade to connect more accounts.","billing.upgrade_dialog.reasons.member_limit":"You've reached the team member limit on your current plan. Upgrade to invite more people.","billing.subscribe.page_title":"Choose your plan","billing.subscribe.eyebrow":"Pricing","billing.subscribe.title":"Choose the right plan for you","billing.subscribe.description":"Pick the plan that fits you. Billed monthly or annually.","billing.subscribe.monthly":"Monthly","billing.subscribe.yearly":"Yearly","billing.subscribe.per_month":"monthly","billing.subscribe.per_year":"yearly","billing.subscribe.billed_monthly":"Billed monthly","billing.subscribe.billed_yearly":"Billed annually","billing.subscribe.features_included":"What's included:","billing.subscribe.everything_in":"Everything in :plan, plus:","billing.subscribe.save_months":"2 months free","billing.subscribe.popular":"Most popular","billing.subscribe.subscribe_cta":"Subscribe","billing.subscribe.prices.starter.monthly":"$19","billing.subscribe.prices.starter.yearly_per_month":"$16","billing.subscribe.prices.starter.yearly":"$190","billing.subscribe.prices.plus.monthly":"$29","billing.subscribe.prices.plus.yearly_per_month":"$24","billing.subscribe.prices.plus.yearly":"$290","billing.subscribe.prices.pro.monthly":"$49","billing.subscribe.prices.pro.yearly_per_month":"$41","billing.subscribe.prices.pro.yearly":"$490","billing.subscribe.prices.max.monthly":"$99","billing.subscribe.prices.max.yearly_per_month":"$83","billing.subscribe.prices.max.yearly":"$990","billing.subscribe.features.social_accounts":":count social accounts","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count team members","billing.subscribe.features.credits":":count AI credits/mo","billing.subscribe.credit_tooltips.starter":"Roughly 150 medium-length posts plus 5 AI images per month.","billing.subscribe.credit_tooltips.plus":"Roughly 300 medium-length posts plus 10 AI images per month.","billing.subscribe.credit_tooltips.pro":"Roughly 700 medium-length posts plus 30 AI images per month.","billing.subscribe.credit_tooltips.max":"Roughly 2,000 medium-length posts plus 100 AI images per month.","billing.plan.title":"Plan","billing.plan.description":"Manage your subscription plan.","billing.plan.change":"Change plan","billing.plan.label":"Plan","billing.plan.price":"Price","billing.plan.month":"month","billing.plan.trial":"Trial","billing.plan.active":"Active","billing.plan.past_due":"Past due","billing.plan.cancelling":"Cancelling","billing.plan.trial_ends":"Trial ends","billing.subscription.title":"Subscription","billing.subscription.description":"Manage your payment method, billing details, and subscription.","billing.subscription.payment_method":"Payment method","billing.subscription.no_payment_method":"No payment method on file yet.","billing.subscription.expires_on":"Expires :month/:year","billing.subscription.manage_label":"Subscription","billing.subscription.manage_stripe":"Manage on Stripe","billing.invoices.title":"Invoices","billing.invoices.description":"Download your past invoices.","billing.invoices.empty":"No invoices found","billing.invoices.paid":"Paid","billing.flash.plan_changed":"You are now on the :plan plan.","billing.flash.cannot_manage":"Only the account owner can manage billing.","billing.flash.cannot_downgrade.workspaces":"Cannot switch to :plan: you have :count workspaces but the plan only allows :limit.","billing.flash.cannot_downgrade.social_accounts":"Cannot switch to :plan: you have :count social accounts but the plan only allows :limit.","billing.flash.cannot_downgrade.members":"Cannot switch to :plan: you have :count team members (including invites) but the plan only allows :limit.","billing.flash.credits_exhausted":"Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.","billing.processing.page_title":"Processing...","billing.processing.title":"Processing your subscription","billing.processing.description":"Please wait while we set up your account. This will only take a moment.","billing.processing.success_title":"You're all set!","billing.processing.success_description":"Your subscription is active. Redirecting you to your workspaces...","billing.processing.cancelled_title":"Checkout cancelled","billing.processing.cancelled_description":"Your checkout was cancelled. No charges were made.","billing.processing.retry":"Try again","brands.new_brand":"New Brand","brands.no_brands_yet":"No brands yet","brands.no_brands_description":"Create brands to organize your social accounts by client or project","brands.accounts_count":":count accounts","brands.create.title":"Create Brand","brands.create.description":"Give your brand a name to group social accounts","brands.create.name":"Brand Name","brands.create.name_placeholder":"e.g. Acme Corp, Personal","brands.create.submit":"Create Brand","brands.create.submitting":"Creating...","brands.edit.title":"Edit Brand","brands.edit.description":"Update the name of this brand","brands.edit.name":"Brand Name","brands.edit.name_placeholder":"e.g. Acme Corp, Personal","brands.edit.submit":"Save Changes","brands.edit.submitting":"Saving...","brands.delete.title":"Delete Brand","brands.delete.description":"Are you sure you want to delete this brand? Social accounts will be unassigned but not deleted.","brands.delete.confirm":"Delete","brands.delete.cancel":"Cancel","brands.flash.created":"Brand created successfully!","brands.flash.updated":"Brand updated successfully!","brands.flash.deleted":"Brand deleted successfully!","calendar.title":"Calendar","calendar.today":"Today","calendar.day":"Day","calendar.week":"Week","calendar.month":"Month","calendar.new_post":"New Post","calendar.no_content":"No content","calendar.more":"+:count more","comments.placeholder":"Write a comment...","comments.reply_placeholder":"Write a reply...","comments.reply":"Reply","comments.edit":"Edit","comments.delete":"Delete","comments.edited":"edited","comments.save":"Save","comments.cancel":"Cancel","comments.send":"Send","comments.replying_to":"Replying to :name","comments.empty":"No comments yet. Start the conversation.","comments.load_more":"Load older comments","comments.today":"Today","comments.yesterday":"Yesterday","common.confirm_modal.cannot_be_undone":"This cannot be undone.","common.confirm_modal.type":"Type","common.confirm_modal.to_confirm":"to confirm.","common.confirm_modal.copy_to_clipboard":"Copy to clipboard","common.confirm_modal.delete_keyword":"delete","common.photo_upload.upload":"Upload","common.photo_upload.uploading":"Uploading...","common.photo_upload.remove":"Remove photo","common.photo_upload.hint":"Recommended: square image, max 2 MB.","common.timezone.select":"Select timezone","common.timezone.search":"Search timezone...","common.timezone.empty":"No timezone found","common.date_picker.select":"Select date","common.date_range_picker.placeholder":"Pick a date range","common.date_range_picker.today":"Today","common.date_range_picker.yesterday":"Yesterday","common.date_range_picker.last_7_days":"Last 7 days","common.date_range_picker.last_30_days":"Last 30 days","common.date_range_picker.last_3_months":"Last 3 months","common.date_range_picker.last_6_months":"Last 6 months","common.date_range_picker.last_12_months":"Last 12 months","common.date_range_picker.this_month":"This month","common.date_range_picker.last_month":"Last month","common.date_range_picker.year_to_date":"Year to date","common.date_range_picker.last_year":"Last year","common.cancel":"Cancel","common.clear":"Clear","common.close":"Close","common.loading_more":"Loading more...","labels.title":"Labels","labels.description":"Create labels to organize and categorize your posts","labels.search":"Search labels...","labels.new_label":"New Label","labels.no_labels_yet":"No labels yet","labels.no_search_results":"No labels match your search","labels.try_different_search":"Try a different keyword or clear the search.","labels.create_first_label":"Create your first label","labels.table.name":"Name","labels.table.created_at":"Created","labels.actions.edit":"Edit label","labels.actions.delete":"Delete label","labels.create.title":"Create Label","labels.create.description":"Give your label a name and pick a color","labels.create.name":"Name","labels.create.name_placeholder":"Enter label name...","labels.create.color":"Color","labels.create.submit":"Create Label","labels.create.submitting":"Creating...","labels.edit.title":"Edit Label","labels.edit.description":"Update the name and color for this label","labels.edit.name":"Name","labels.edit.name_placeholder":"Enter label name...","labels.edit.color":"Color","labels.edit.submit":"Save Changes","labels.edit.submitting":"Saving...","labels.delete.title":"Delete Label","labels.delete.description":"Are you sure you want to delete this label? This action cannot be undone.","labels.delete.confirm":"Delete","labels.delete.cancel":"Cancel","labels.flash.created":"Label created successfully!","labels.flash.updated":"Label updated successfully!","labels.flash.deleted":"Label deleted successfully!","mail.mentioned.subject":":name mentioned you on TryPost","mail.mentioned.title":":name mentioned you","mail.mentioned.intro":":name mentioned you in a post comment.","mail.mentioned.cta":"View comment","mail.workspace_connections_disconnected.subject":"{1} :count account needs to be reconnected in :workspace|[2,*] :count accounts need to be reconnected in :workspace","mail.workspace_connections_disconnected.title":"Accounts Need Reconnection","mail.workspace_connections_disconnected.intro":"The following social accounts in your :workspace workspace have been disconnected and need to be reconnected:","mail.workspace_connections_disconnected.reasons_title":"This may have happened because:","mail.workspace_connections_disconnected.reason_expired":"Access tokens expired","mail.workspace_connections_disconnected.reason_revoked":"You revoked access to TryPost on the platform","mail.workspace_connections_disconnected.reason_changed":"The platform changed their authentication requirements","mail.workspace_connections_disconnected.reconnect_cta":"Please reconnect these accounts to continue scheduling and publishing posts.","mail.workspace_connections_disconnected.button":"Reconnect Accounts","notifications.post_ready.title":"Your post is ready","notifications.post_ready.body":"The AI just finished. Tap to review and publish.","notifications.account_disconnected.title":":platform account disconnected","notifications.account_disconnected.body":":account needs to be reconnected","notifications.account_token_expired.title":":platform account needs to be reconnected","notifications.account_token_expired.body":":account session expired — please reconnect to keep posting","posts.title":"Posts","posts.search":"Search posts...","posts.all_posts":"All Posts","posts.new_post":"New Post","posts.no_posts":"No posts found","posts.no_search_results":"No posts match your search","posts.try_different_search":"Try a different keyword or clear the search.","posts.start_creating":"Start by creating your first post.","posts.filter_by_label":"Filter by label","posts.label_search_placeholder":"Search labels...","posts.no_labels":"No labels found.","posts.clear_label_filter":"Clear label filter","posts.table.post":"Post","posts.table.status":"Status","posts.table.content":"Content","posts.table.platforms":"Platforms","posts.table.labels":"Labels","posts.table.scheduled_at":"Date","posts.table.actions":"","posts.manage_posts":"Manage all your posts","posts.delete_confirm":"Are you sure you want to delete this post?","posts.by":"by","posts.actions.view":"View post","posts.actions.delete":"Delete","posts.actions.duplicate":"Duplicate","posts.actions.copy_id":"Copy ID","posts.actions.copied":"ID copied to clipboard","posts.form.post_type":"Post Type","posts.form.board":"Board","posts.form.select_board":"Select a board","posts.form.search_board":"Search board...","posts.form.no_board_found":"No board found","posts.form.media":"Media","posts.form.min":"Min","posts.form.uploading":"Uploading...","posts.form.drop_to_upload":"Drop to upload","posts.form.drag_and_drop":"Drag & drop or click to upload","posts.form.photos_and_videos":"Photos and videos","posts.form.photos_only":"Photos only","posts.form.videos_only":"Videos only","posts.form.drag_to_reorder":"Drag to reorder","posts.form.caption":"Caption","posts.form.write_caption":"Write your caption...","posts.form.content_exceeds_platform":":platform: too long by :over chars (max :limit).","posts.form.tiktok.settings":"TikTok Settings","posts.form.tiktok.variant_label":"Post type","posts.form.tiktok.variant.video":"Video","posts.form.tiktok.variant.photo":"Photo carousel","posts.form.tiktok.posting_to":"Posting to","posts.form.tiktok.privacy_level":"Who can see this video?","posts.form.tiktok.privacy_placeholder":"Select visibility","posts.form.tiktok.privacy.public":"Public to everyone","posts.form.tiktok.privacy.friends":"Mutual follow friends","posts.form.tiktok.privacy.followers":"Followers","posts.form.tiktok.privacy.private":"Only me","posts.form.tiktok.privacy.private_disabled_branded":"Branded content visibility cannot be set to private.","posts.form.tiktok.privacy_hint":"The available options depend on your TikTok account settings.","posts.form.tiktok.auto_add_music":"Auto add music","posts.form.tiktok.auto_add_music_hint":"This feature is available only for photos. It will add a default music that you can change later.","posts.form.tiktok.yes":"Yes","posts.form.tiktok.no":"No","posts.form.tiktok.allow_users":"Allow users to:","posts.form.tiktok.comments":"Comment","posts.form.tiktok.duet":"Duet","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Video made with AI","posts.form.tiktok.disclose":"Disclose video content","posts.form.tiktok.disclose_hint":"Turn on to disclose that this video promotes goods or services in exchange for something of value. Your video could promote yourself, a third party, or both.","posts.form.tiktok.promotional_organic_title":"Your photo/video will be labeled as \"Promotional content\".","posts.form.tiktok.promotional_paid_title":"Your photo/video will be labeled as \"Paid partnership\".","posts.form.tiktok.promotional_description":"This cannot be changed once your video is posted.","posts.form.tiktok.compliance_incomplete":"You need to indicate if your content promotes yourself, a third party, or both.","posts.form.tiktok.privacy_required":"TikTok privacy level is required when publishing.","posts.form.tiktok.branded_cleared_private":"Privacy was cleared because Branded Content cannot be private.","posts.form.tiktok.interaction_disabled_by_creator":"Disabled by your TikTok account settings.","posts.form.tiktok.max_duration_exceeded":"Video is :duration s long but this account can only post videos up to :max s.","posts.form.tiktok.processing_hint":"After publishing, it may take a few minutes for the content to process and appear on your TikTok profile.","posts.form.tiktok.brand_organic":"Your brand","posts.form.tiktok.brand_organic_hint":"You are promoting yourself or your own brand. This video will be classified as Brand Organic.","posts.form.tiktok.brand_content":"Branded content","posts.form.tiktok.brand_content_hint":"You are promoting another brand or a third party. This video will be classified as Branded Content.","posts.form.tiktok.compliance.agree":"By posting, you agree to TikTok's","posts.form.tiktok.compliance.music_usage":"Music Usage Confirmation","posts.form.tiktok.compliance.and":"and","posts.form.tiktok.compliance.branded_policy":"Branded Content Policy","posts.form.instagram.settings":"Instagram Settings","posts.form.instagram.posting_to":"Posting to","posts.form.instagram.variant_label":"Post type","posts.form.instagram.variant.feed":"Feed Post","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Story","posts.form.instagram.aspect_label":"Aspect ratio","posts.form.instagram.aspect.square":"Square (1:1)","posts.form.instagram.aspect.portrait":"Portrait (4:5)","posts.form.instagram.aspect.landscape":"Landscape (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Facebook Settings","posts.form.facebook.posting_to":"Posting to","posts.form.facebook.variant_label":"Post type","posts.form.facebook.variant.post":"Post","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Story","posts.form.linkedin.settings":"LinkedIn Settings","posts.form.linkedin.settings_page":"LinkedIn Page Settings","posts.form.linkedin.posting_to":"Posting to","posts.form.linkedin.variant_label":"Post type","posts.form.linkedin.variant.post":"Post","posts.form.linkedin.variant.carousel":"Carousel","posts.form.pinterest.settings":"Pinterest Settings","posts.form.pinterest.posting_to":"Posting to","posts.form.pinterest.variant_label":"Pin type","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carousel","posts.form.pinterest.board":"Board","posts.form.pinterest.select_board":"Select a board","posts.form.pinterest.no_boards":"No Pinterest boards found. Create one in your Pinterest account first.","posts.form.pinterest.search_board":"Search boards...","posts.form.pinterest.no_board_found":"No board matches your search.","posts.form.pinterest.board_required":"Select a Pinterest board to publish this post.","posts.form.warnings.no_variant":"Pick a post type to continue.","posts.form.warnings.requires_media":"This post type requires at least one image or video.","posts.form.warnings.max_files_exceeded":"This post type accepts up to :max media files (you have :current).","posts.form.warnings.min_files_required":"This post type requires at least :min media files (you have :current).","posts.form.warnings.no_video_allowed":"This post type does not accept videos.","posts.form.warnings.no_image_allowed":"This post type accepts only videos.","posts.form.warnings.gif_not_allowed":"This platform does not accept GIF. Remove the GIF or choose a different network.","posts.form.warnings.image_too_large":"Image exceeds the :max limit for this post type (yours is :current).","posts.form.warnings.video_too_large":"Video exceeds the :max limit for this post type (yours is :current).","posts.form.warnings.video_too_long":"Video is :current long, but this post type allows up to :max.","posts.form.warnings.aspect_ratio_too_narrow":"Aspect ratio :current is too tall for this post type (min :min).","posts.form.warnings.aspect_ratio_too_wide":"Aspect ratio :current is too wide for this post type (max :max).","posts.status.pending":"Pending","posts.status.draft":"Draft","posts.status.scheduled":"Scheduled","posts.status.publishing":"Publishing","posts.status.retrying":"Retrying","posts.status.published":"Published","posts.status.partially_published":"Partially Published","posts.status.failed":"Failed","posts.descriptions.draft":"Posts waiting to be scheduled","posts.descriptions.scheduled":"Posts scheduled for publishing","posts.descriptions.published":"Posts already published","posts.ai.generate.button_tooltip":"Generate with AI","posts.ai.generate.title":"Generate post with AI","posts.ai.generate.description":"Describe what the post should be about. The AI will use your brand context to write it.","posts.ai.generate.prompt_label":"What is this post about?","posts.ai.generate.prompt_placeholder":"e.g. Announce our new image-generation feature for carousels","posts.ai.generate.preview_label":"Preview","posts.ai.generate.start":"Generate","posts.ai.generate.apply":"Use this content","posts.ai.generate.retry":"Try again","posts.ai.generate.cancel":"Cancel","posts.ai.review.button_tooltip":"Review with AI","posts.ai.review.title":"Review post with AI","posts.ai.review.description":"AI scans for grammar, spelling, and clarity. Apply the suggestions you agree with.","posts.ai.review.loading":"Reviewing your text...","posts.ai.review.no_issues":"No issues found. Looks good.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Suggestion","posts.ai.review.apply":"Apply","posts.ai.review.apply_all":"Apply all","posts.ai.review.applied":"Applied","posts.ai.review.cancel":"Cancel","posts.show.title":"Post Details","posts.show.edit":"Edit","posts.show.back":"Back","posts.show.no_content":"No caption","posts.show.platforms":"Platforms","posts.show.no_platforms":"No platforms selected.","posts.show.view_on_platform":"View on platform","posts.show.published_on":"Published on :date","posts.show.scheduled_for":"Scheduled for :date","posts.show.draft":"Draft","posts.show.status_pending":"Pending","posts.show.metrics":"Metrics","posts.show.metrics_loading":"Loading metrics…","posts.show.metrics_unavailable":"Metrics unavailable for this platform yet.","posts.show.metrics_empty":"No metrics returned.","posts.edit.title":"Edit Post","posts.edit.view_title":"View Post","posts.edit.labels":"Labels","posts.edit.no_labels":"No labels created yet","posts.edit.schedule":"Schedule","posts.edit.pick_time":"Pick time","posts.edit.post_now":"Post now","posts.edit.time":"Time","posts.edit.cancel":"Cancel","posts.edit.delete":"Delete","posts.edit.schedule_for":"Schedule for","posts.edit.schedule_date":"Schedule date","posts.edit.unschedule":"Unschedule","posts.edit.saving":"Saving...","posts.edit.saved":"Saved","posts.edit.draft":"Draft","posts.edit.media":"Media","posts.edit.add_media":"Add media","posts.edit.caption":"Caption","posts.edit.caption_placeholder":"Write your caption...","posts.edit.compose_title":"Create a post","posts.edit.compose_subtitle":"Compose your message and add media","posts.edit.preview_empty.title":"No platform selected","posts.edit.preview_empty.description":"Select a platform to publish to see the preview.","posts.edit.drop_zone_title":"Add media","posts.edit.drop_zone_subtitle":"Drag & drop files or click to browse","posts.edit.add":"Add","posts.edit.publish_to":"Publish to","posts.edit.organize":"Organize","posts.edit.signatures":"Signatures","posts.edit.view_on_platform":"View on platform","posts.edit.platform_status":"Platform status","posts.edit.compliance_incomplete":"Some platform settings are incomplete or incompatible with the attached media.","posts.edit.compliance.requires_media":"Add an image or video to publish here.","posts.edit.compliance.too_many_files":"Only :max file(s) allowed for this format.","posts.edit.compliance.too_few_files":"Add at least :min files for this format.","posts.edit.compliance.no_videos":"Only images are allowed for this format.","posts.edit.compliance.no_images":"Only videos are allowed for this format.","posts.edit.compliance.no_gifs":"GIFs are not supported here.","posts.edit.compliance.video_too_large":"Video exceeds the size limit for this platform.","posts.edit.compliance.video_too_long":"Video must be under :seconds seconds for this format.","posts.edit.compliance.image_too_large":"Image exceeds the size limit for this platform.","posts.edit.compliance.aspect_ratio_invalid":"Aspect ratio is not supported by this format.","posts.edit.compliance.no_content_type":"Pick a content type for this platform.","posts.edit.publishing":"Publishing...","posts.edit.publishing_overlay_title":"Your post is being published","posts.edit.publishing_overlay_subtitle":"This can take a few moments. You can safely leave this page.","posts.edit.scheduled_overlay_title":"This post is scheduled","posts.edit.scheduled_overlay_subtitle":"Scheduled for :date. Unschedule it first to make changes.","posts.edit.unschedule_cta":"Unschedule to edit","posts.edit.tabs.preview":"Preview","posts.edit.tabs.schedule":"Schedule","posts.edit.tabs.comments":"Comments","posts.edit.tabs.comments_empty":"No comments yet.","posts.edit.media_picker.title":"Pick from gallery","posts.edit.media_picker.search":"Search media...","posts.edit.media_picker.empty":"No media in your gallery yet","posts.edit.media_picker.cancel":"Cancel","posts.edit.media_picker.add":"Add","posts.edit.media_picker.add_count":"Add :count","posts.edit.emoji_picker.search":"Search emoji","posts.edit.emoji_picker.empty":"No emojis found","posts.edit.emoji_picker.recent":"Frequently used","posts.edit.emoji_picker.smileys":"Smileys & emotion","posts.edit.emoji_picker.people":"People & body","posts.edit.emoji_picker.nature":"Animals & nature","posts.edit.emoji_picker.food":"Food & drink","posts.edit.emoji_picker.activities":"Activities","posts.edit.emoji_picker.travel":"Travel & places","posts.edit.emoji_picker.objects":"Objects","posts.edit.emoji_picker.symbols":"Symbols","posts.edit.emoji_picker.flags":"Flags","posts.edit.status.scheduled":"Scheduled","posts.edit.status.published":"Published","posts.edit.status.publishing":"Publishing...","posts.edit.status.retrying":"Retrying...","posts.edit.status.failed":"Failed","posts.edit.delete_modal.title":"Delete Post","posts.edit.delete_modal.description":"Are you sure you want to delete this post? This action cannot be undone.","posts.edit.delete_modal.action":"Delete","posts.edit.delete_modal.cancel":"Cancel","posts.edit.sync_enable.title":"Enable sync?","posts.edit.sync_enable.description":"All platforms will share the same content. Any custom edits made to individual platforms will be replaced with the current content.","posts.edit.sync_enable.cancel":"Cancel","posts.edit.sync_enable.action":"Enable sync","posts.edit.sync_disable.title":"Disable sync?","posts.edit.sync_disable.description":"Each platform will keep its current content, but future edits will only apply to the platform you're editing.","posts.edit.sync_disable.customize_note":"You'll be able to customize the content for each platform individually.","posts.edit.sync_disable.cancel":"Cancel","posts.edit.sync_disable.action":"Disable sync","posts.edit.platforms_dialog.title":"Select Platforms","posts.edit.platforms_dialog.description":"Choose which platforms to publish this post to.","posts.edit.signatures_modal.search":"Search signatures...","posts.edit.signatures_modal.no_results":"No signatures found.","posts.edit.validation.select_board":"Select a board","posts.edit.validation.images_not_supported":"Images not supported","posts.edit.validation.videos_not_supported":"Videos not supported","posts.edit.validation.max_images":"Max :count images","posts.edit.validation.requires_media":"Requires media","posts.edit.validation.requires_content":"Text content is required","posts.edit.validation.exceeded":":count exceeded","posts.edit.validation.does_not_support_images":":platform does not support images","posts.edit.validation.supports_up_to_images":":platform supports up to :count images","posts.edit.validation.does_not_support_videos":":platform does not support videos","posts.content_types.instagram_feed.label":"Feed Post","posts.content_types.instagram_feed.description":"Appears in your feed and profile","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Short video up to 90 seconds","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Disappears after 24 hours","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Standard post with text and media","posts.content_types.linkedin_carousel.label":"Carousel","posts.content_types.linkedin_carousel.description":"Swipeable images","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Standard post with text and media","posts.content_types.linkedin_page_carousel.label":"Carousel","posts.content_types.linkedin_page_carousel.description":"Swipeable images","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Standard post on your page","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Short video up to 90 seconds","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Disappears after 24 hours","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Short-form video content","posts.content_types.tiktok_photo.label":"Photo carousel","posts.content_types.tiktok_photo.description":"Up to 35 photos as a swipeable carousel","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vertical video up to 60 seconds","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet with text and media","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Text post with optional media","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Standard image pin","posts.content_types.pinterest_video_pin.label":"Video Pin","posts.content_types.pinterest_video_pin.description":"Video pin (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carousel","posts.content_types.pinterest_carousel.description":"Multi-image carousel (2-5 images)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Text post with optional images","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Text post with optional media","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"LinkedIn Page","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Facebook Page","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post scheduled successfully!","posts.flash.deleted":"Post deleted successfully!","posts.flash.duplicated":"Post duplicated as a draft.","posts.flash.cannot_edit_published":"Published posts cannot be edited.","posts.flash.cannot_delete_published":"Published posts cannot be deleted.","posts.flash.connect_first":"Connect at least one social network before creating a post.","posts.errors.account_disconnected":"Social account is disconnected","posts.errors.account_inactive":"Social account is deactivated","posts.errors.account_token_expired":"Social account session expired — please reconnect","posts.delete.title":"Delete post?","posts.delete.description":"This action can't be undone. The post and all its media will be permanently removed.","posts.delete.confirm":"Yes, delete","posts.delete.cancel":"Cancel","posts.create.title":"Create a new post","posts.create.description":"Choose how you want to start.","posts.create.scratch_title":"Start from scratch","posts.create.scratch_description":"Open a blank post and write everything yourself.","posts.create.ai_title":"Generate with AI","posts.create.ai_description":"Describe what you want and AI generates the content for you.","posts.create.ai_configure_description":"Pick a format and describe the post you want to create.","posts.create.template_title":"Use a template","posts.create.template_description":"Pick from our curated templates and customize.","posts.create.coming_soon":"Coming soon","posts.create.preview.image_title":"Image title","posts.create.preview.image_body":"Image body","posts.create.steps.format_title":"Choose a format","posts.create.steps.format_description":"Select the type of post you want to create.","posts.create.steps.account_title":"Choose an account","posts.create.steps.account_description":"Select the social account to publish to.","posts.create.steps.media_title":"Media options","posts.create.steps.media_carousel":"How many slides?","posts.create.steps.media_optional":"Include images?","posts.create.steps.media_optional_label":"How many images?","posts.create.steps.media_none":"None","posts.create.steps.media_count_label":"Number of images","posts.create.steps.prompt_title":"Describe your post","posts.create.steps.prompt_label":"What is this post about?","posts.create.steps.prompt_placeholder":"e.g. Announce our new carousel feature for Instagram","posts.create.steps.preview_error":"Something went wrong. Please try again.","posts.create.steps.loading_page_title":"Generating your post","posts.create.steps.loading_eta":"Estimated time: about :minutes.","posts.create.steps.loading_eta_minute_one":"1 minute","posts.create.steps.loading_eta_minute_other":":count minutes","posts.create.steps.loading_leave_title":"You can keep working.","posts.create.steps.loading_leave_body":"We will notify you when the post is ready.","posts.create.steps.loading_leave_cta":"Go to calendar","posts.create.steps.loading_create_another_cta":"Create another post","posts.create.steps.loading_tip_credits":"Each AI image uses about 15 credits.","posts.create.steps.loading_tip_edit":"You will be able to edit everything once the post is ready.","posts.create.steps.loading_tip_draft":"Generated posts land in your drafts.","posts.create.steps.loading_tip_brand":"Tweak your brand settings to influence future posts.","posts.create.steps.loading_tip_carousel":"Carousels deliver one slide per uploaded image.","posts.create.steps.loading_tip_quality":"Image quality is set to balance speed and cost.","posts.create.steps.create":"Create post","posts.create.steps.back":"Back","posts.create.steps.next":"Continue","posts.create.steps.cancel":"Cancel","posts.create.steps.discard":"Discard","posts.create.steps.retry":"Try again","posts.create.steps.no_platforms":"No connected accounts","posts.create.steps.connect_first":"Connect at least one social account to use AI generation.","posts.create.steps.format.instagram_feed":"Instagram Feed Post","posts.create.steps.format.instagram_carousel":"Instagram Carousel","posts.create.steps.format.linkedin_post":"LinkedIn Post","posts.create.steps.format.linkedin_page_post":"LinkedIn Page Post","posts.create.steps.format.x_post":"X Post","posts.create.steps.format.bluesky_post":"Bluesky Post","posts.create.steps.format.threads_post":"Threads Post","posts.create.steps.format.mastodon_post":"Mastodon Post","posts.create.steps.format.facebook_post":"Facebook Post","posts.create.steps.format.pinterest_pin":"Pinterest Pin","posts.create.steps.format.instagram_story":"Instagram Story","posts.create.steps.format.facebook_story":"Facebook Story","posts.templates.browser_title":"Choose a template","posts.templates.browser_description":"Start from a curated template and adapt it.","posts.templates.search_placeholder":"Search templates…","posts.templates.no_search_results":"No templates match your search","posts.templates.try_different_search":"Try a different keyword or clear the search.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.all_platforms":"All platforms","posts.templates.platform_search_placeholder":"Search platform…","posts.templates.no_platform_match":"No platform matches.","posts.templates.use_this":"Use this template","posts.templates.no_templates":"No templates available.","posts.templates.applying":"Applying template…","posts.templates.category.product_launch":"Product launch","posts.templates.category.promotion":"Promotion","posts.templates.category.educational":"Educational","posts.templates.category.behind_the_scenes":"Behind the scenes","posts.templates.category.testimonial":"Testimonial","posts.templates.category.industry_tip":"Industry tip","posts.templates.category.event":"Event","posts.templates.category.engagement":"Engagement","settings.title":"Settings","settings.description":"Manage your profile and account settings","settings.hub.title":"Settings","settings.hub.description":"Choose what you want to manage.","settings.hub.profile.title":"Profile","settings.hub.profile.description":"Update your personal info, password, and notification preferences.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configure your workspace, brand, members, and API keys.","settings.hub.account.title":"Account","settings.hub.account.description":"Manage your account info, usage, and billing.","settings.nav.profile":"Profile","settings.nav.authentication":"Authentication","settings.nav.workspace":"Workspace","settings.nav.members":"Members","settings.nav.notifications":"Notifications","settings.nav.billing":"Billing","settings.notifications.title":"Notification preferences","settings.notifications.heading":"Email notifications","settings.notifications.description":"Choose which email notifications you want to receive","settings.notifications.post_published":"Post published","settings.notifications.post_published_description":"Receive an email when your post is published successfully","settings.notifications.post_failed":"Post failed","settings.notifications.post_failed_description":"Receive an email when your post fails to publish","settings.notifications.account_disconnected":"Account disconnected","settings.notifications.account_disconnected_description":"Receive an email when a social account is disconnected","settings.notifications.save":"Save preferences","settings.profile.title":"Profile settings","settings.profile.photo_heading":"Profile photo","settings.profile.photo_description":"Upload a profile photo","settings.profile.heading":"Profile information","settings.profile.description":"Update your name and email address","settings.profile.avatar":"Avatar","settings.profile.name":"Name","settings.profile.name_placeholder":"Full name","settings.profile.email":"Email address","settings.profile.email_placeholder":"Email address","settings.profile.email_unverified":"Your email address is unverified.","settings.profile.resend_verification":"Click here to resend the verification email.","settings.profile.verification_sent":"A new verification link has been sent to your email address.","settings.profile.save":"Save","settings.authentication.title":"Authentication","settings.authentication.page_title":"Authentication settings","settings.authentication.sessions.title":"Active sessions","settings.authentication.sessions.description":"If you notice anything suspicious, sign out of other devices.","settings.authentication.sessions.unknown_browser":"Unknown browser","settings.authentication.sessions.unknown_ip":"Unknown IP","settings.authentication.sessions.on":"on","settings.authentication.sessions.active_now":"Active now","settings.authentication.sessions.log_out_others":"Log out other devices","settings.authentication.sessions.modal_title":"Log out other devices","settings.authentication.sessions.modal_description_password":"Enter your current password to confirm you want to log out other browser sessions.","settings.authentication.sessions.modal_description_email":"Type your email address to confirm you want to log out other browser sessions.","settings.authentication.sessions.password_placeholder":"Current password","settings.authentication.sessions.email_placeholder":"Your account email","settings.authentication.sessions.cancel":"Cancel","settings.authentication.sessions.submit":"Log out other devices","settings.authentication.sessions.email_mismatch":"The email address does not match your account.","settings.authentication.sessions.flash_logged_out":"You have been logged out from other devices.","settings.authentication.password.update_title":"Update password","settings.authentication.password.set_title":"Set a password","settings.authentication.password.update_description":"Ensure your account is using a long, random password to stay secure.","settings.authentication.password.set_description":"Add a password so you can sign in without a connected provider.","settings.authentication.password.current_password":"Current password","settings.authentication.password.new_password":"New password","settings.authentication.password.confirm_password":"Confirm password","settings.authentication.password.save":"Save password","settings.authentication.password.set":"Set password","settings.authentication.providers.title":"Connected accounts","settings.authentication.providers.description":"Sign in faster with these connected providers.","settings.authentication.providers.connected":"Connected","settings.authentication.providers.not_connected":"Not connected","settings.authentication.providers.connect":"Connect","settings.authentication.providers.disconnect":"Disconnect","settings.authentication.providers.flash_disconnected":":provider disconnected successfully.","settings.authentication.providers.flash_connected":":provider connected successfully.","settings.authentication.providers.flash_already_linked":"That :provider account is already linked to another user.","settings.authentication.providers.flash_cannot_disconnect":"You cannot disconnect your only sign-in method. Set a password or connect another provider first.","settings.delete_account.heading":"Delete account","settings.delete_account.description":"Delete your account and all of its resources","settings.delete_account.warning":"Warning","settings.delete_account.warning_message":"Please proceed with caution, this cannot be undone.","settings.delete_account.button":"Delete account","settings.delete_account.modal_title":"Are you sure you want to delete your account?","settings.delete_account.modal_description_password":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm.","settings.delete_account.modal_description_email":"Once your account is deleted, all of its resources and data will also be permanently deleted. Please type your email address :email to confirm.","settings.delete_account.password":"Password","settings.delete_account.password_placeholder":"Password","settings.delete_account.email_placeholder":"Your account email","settings.delete_account.email_mismatch":"The email address does not match your account.","settings.delete_account.cancel":"Cancel","settings.delete_account.confirm":"Delete account","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Brand","settings.workspace.tabs.users":"Members","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Workspace settings","settings.workspace.logo_heading":"Workspace logo","settings.workspace.logo_description":"Upload a logo for your workspace","settings.workspace.heading":"Workspace name","settings.workspace.description":"Update your workspace name","settings.workspace.members_heading":"Members","settings.workspace.members_description":"Manage workspace members and invitations","settings.workspace.name":"Name","settings.workspace.name_placeholder":"My Workspace","settings.workspace.save":"Save","settings.brand.title":"Brand","settings.brand.description":"Configure your brand identity for AI-generated content.","settings.brand.name":"Workspace name","settings.brand.name_placeholder":"My brand","settings.brand.website":"Website","settings.brand.website_placeholder":"https://yourbrand.com","settings.brand.brand_description":"Description","settings.brand.brand_description_placeholder":"Tell us about your brand, what you do, and who your audience is...","settings.brand.tone":"Tone of voice","settings.brand.tone_professional":"Professional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Friendly","settings.brand.tone_bold":"Bold","settings.brand.tone_inspirational":"Inspirational","settings.brand.tone_humorous":"Humorous","settings.brand.tone_educational":"Educational","settings.brand.voice_notes":"Voice notes","settings.brand.voice_notes_placeholder":"Additional writing guidelines, words to avoid, style preferences...","settings.brand.brand_color":"Brand color","settings.brand.background_color":"Background color","settings.brand.text_color":"Text color","settings.brand.font":"Font","settings.brand.image_style":"Image style","settings.brand.image_style_description":"Visual style applied when generating slide and cover images for AI posts.","settings.brand.image_style_cinematic":"Cinematic","settings.brand.image_style_illustration":"Illustration","settings.brand.image_style_isometric_3d":"Isometric","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Typographic","settings.brand.image_style_infographic":"Infographic","settings.brand.image_style_minimalist":"Minimalist","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Content language","settings.brand.content_language_description":"Language used for AI-generated captions, hashtags, and any text inside generated images or videos.","settings.members.title":"Members","settings.members.heading":"Team members","settings.members.description":"Manage members and invites for this workspace","settings.members.cancel":"Cancel","settings.members.remove":"Remove","settings.members.make_admin":"Make admin","settings.members.make_member":"Make member","settings.members.invite.title":"Invite Member","settings.members.invite.description":"Send an email invite to add collaborators","settings.members.invite.email":"Email","settings.members.invite.email_placeholder":"collaborator@email.com","settings.members.invite.role":"Role","settings.members.invite.role_placeholder":"Select a role","settings.members.invite.submit":"Send Invite","settings.members.pending.title":"Pending Invites","settings.members.pending.description":"Invites awaiting acceptance","settings.members.pending.empty":"No pending invites","settings.members.list.title":"Members","settings.members.list.description":"People with access to this workspace","settings.members.list.empty":"No members besides the owner","settings.members.remove_modal.title":"Remove member","settings.members.remove_modal.description":"Are you sure you want to remove this member from the workspace? They will lose access to all workspace resources.","settings.members.remove_modal.action":"Remove member","settings.members.cancel_invite_modal.title":"Cancel invitation","settings.members.cancel_invite_modal.description":"Are you sure you want to cancel this invitation?","settings.members.cancel_invite_modal.action":"Cancel invitation","settings.members.roles.owner":"Owner","settings.members.roles.admin":"Admin","settings.members.roles.member":"Member","settings.members.roles.viewer":"Viewer","settings.members.flash.invite_sent":"Invite sent successfully!","settings.members.flash.invite_deleted":"Invite deleted.","settings.members.flash.member_removed":"Member removed successfully.","settings.members.flash.role_updated":"Member role updated.","settings.members.flash.wrong_email":"This invite is for a different email address.","settings.members.flash.already_member":"You are already a member of this workspace.","settings.members.flash.invite_accepted":"Welcome! You are now a member of the workspace.","settings.members.flash.invite_declined":"Invite declined.","settings.account.tabs.account":"Account","settings.account.tabs.usage":"Usage","settings.account.tabs.billing":"Billing","settings.account.title":"Account Settings","settings.account.description":"Manage your account name and billing email","settings.account.name":"Account Name","settings.account.name_placeholder":"My Company","settings.account.billing_email":"Billing Email","settings.account.billing_email_placeholder":"billing@company.com","settings.account.billing_email_hint":"This email will be used for invoices and billing communications from Stripe.","settings.account.submit":"Save","settings.flash.account_updated":"Account updated successfully!","settings.flash.profile_updated":"Profile updated successfully!","settings.flash.language_updated":"Language updated successfully!","settings.flash.password_updated":"Password updated successfully!","settings.flash.workspace_updated":"Settings updated successfully!","settings.flash.photo_updated":"Photo updated successfully!","settings.flash.photo_deleted":"Photo removed successfully!","settings.flash.logo_updated":"Logo uploaded successfully!","settings.flash.logo_deleted":"Logo removed successfully!","settings.flash.notifications_updated":"Notification preferences updated!","settings.api_keys.title":"API Keys","settings.api_keys.page_title":"API Keys","settings.api_keys.heading":"API Keys","settings.api_keys.description":"Manage API keys for programmatic access to your workspace.","settings.api_keys.create":"Create API Key","settings.api_keys.copy":"Copy","settings.api_keys.new_token_message":"Your new API key has been created. Copy it now — you won't be able to see it again.","settings.api_keys.table.name":"Name","settings.api_keys.table.key":"Key","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expires","settings.api_keys.table.last_used":"Last Used","settings.api_keys.table.never":"Never","settings.api_keys.actions.copy_id":"Copy API Key ID","settings.api_keys.actions.copy_id_success":"API Key ID copied to clipboard","settings.api_keys.actions.delete":"Delete","settings.api_keys.empty.title":"No API keys yet","settings.api_keys.empty.description":"Create an API key to access your workspace programmatically.","settings.api_keys.delete_modal.title":"Delete API key","settings.api_keys.delete_modal.description":"Are you sure you want to delete this API key? Any applications using this key will lose access immediately.","settings.api_keys.delete_modal.action":"Delete API key","settings.api_keys.create_dialog.title":"Create API Key","settings.api_keys.create_dialog.description":"Create a new API key for programmatic access to your workspace.","settings.api_keys.create_dialog.name":"Name","settings.api_keys.create_dialog.name_placeholder":"e.g. Production API Key","settings.api_keys.create_dialog.expires":"Expiration date (optional)","settings.api_keys.create_dialog.expires_placeholder":"No expiration","settings.api_keys.create_dialog.submit":"Create","settings.api_keys.create_dialog.cancel":"Cancel","settings.api_keys.flash.created":"API key created successfully!","settings.api_keys.flash.deleted":"API key deleted successfully!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Select workspace","sidebar.create_workspace":"Create workspace","sidebar.create_post":"Create post","sidebar.profile":"Profile","sidebar.log_out":"Log out","sidebar.workspace.connections":"Connections","sidebar.workspace.signatures":"Signatures","sidebar.workspace.labels":"Labels","sidebar.workspace.assets":"Assets","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Select","sidebar.theme":"Theme: :name","sidebar.theme_light":"Light","sidebar.theme_dark":"Dark","sidebar.theme_system":"System","sidebar.language":"Language: :name","sidebar.language_select":"Language: Select","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Support","sidebar.analytics":"Analytics","sidebar.settings":"Settings","sidebar.posts.calendar":"Calendar","sidebar.posts.all":"All","sidebar.posts.scheduled":"Scheduled","sidebar.posts.posted":"Posted","sidebar.posts.drafts":"Drafts","sidebar.notifications":"Notifications","sidebar.mark_all_read":"Mark all as read","sidebar.mark_as_read":"Mark as read","sidebar.archive_all":"Archive all","sidebar.no_notifications":"No notifications","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Share feedback","sidebar.support.last_updates":"Last Updates","sidebar.support.docs":"Documentation","signatures.title":"Signatures","signatures.description":"Create reusable signatures to quickly append to your posts","signatures.search":"Search signatures...","signatures.new":"New signature","signatures.empty_title":"No signatures yet","signatures.empty_description":"Create signatures to quickly append hashtags, links, or any reusable text to your posts","signatures.no_search_results":"No signatures match your search","signatures.try_different_search":"Try a different keyword or clear the search.","signatures.table.name":"Name","signatures.table.content":"Content","signatures.table.created_at":"Created","signatures.actions.edit":"Edit signature","signatures.actions.delete":"Delete signature","signatures.create.title":"Create signature","signatures.create.description":"Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).","signatures.create.name":"Name","signatures.create.name_placeholder":"e.g. Marketing, Travel, Brand sign-off","signatures.create.content":"Content","signatures.create.content_placeholder":"#marketing #socialmedia\nLearn more: https://yourbrand.com","signatures.create.content_hint":"Hashtags, links, custom intros, signoffs — anything you append to posts.","signatures.create.submit":"Create signature","signatures.create.submitting":"Creating...","signatures.edit.title":"Edit signature","signatures.edit.description":"Update the name and content for this signature.","signatures.edit.name":"Name","signatures.edit.name_placeholder":"e.g. Marketing, Travel, Brand sign-off","signatures.edit.content":"Content","signatures.edit.content_placeholder":"#marketing #socialmedia\nLearn more: https://yourbrand.com","signatures.edit.content_hint":"Hashtags, links, custom intros, signoffs — anything you append to posts.","signatures.edit.submit":"Save changes","signatures.edit.submitting":"Saving...","signatures.delete.title":"Delete signature","signatures.delete.description":"Are you sure you want to delete this signature? This action cannot be undone.","signatures.delete.confirm":"Delete","signatures.delete.cancel":"Cancel","signatures.flash.created":"Signature created.","signatures.flash.updated":"Signature updated.","signatures.flash.deleted":"Signature deleted.","usage.title":"Usage","usage.section_account":"Account","usage.section_account_description":"Quotas and limits for your :plan plan.","usage.section_ai":"AI Credits","usage.section_ai_description":"Credits are debited as AI features are used. They reset on the first of every month.","usage.workspaces":"Workspaces","usage.social_accounts":"Social Accounts","usage.members":"Members","usage.credits":"Credits","workspaces.title":"Workspaces","workspaces.select_title":"Your workspaces","workspaces.select_description":"Select a workspace to continue","workspaces.current":"Current","workspaces.connections":":count connections","workspaces.posts":":count posts","workspaces.create.page_title":"Create your workspace","workspaces.create.title":"Set up your workspace","workspaces.create.description":"Tell us a bit about you or your project. We'll use it to tailor AI-generated posts to your voice.","workspaces.create.website":"Website","workspaces.create.website_placeholder":"https://yourbrand.com","workspaces.create.autofill":"Autofill from website","workspaces.create.autofill_missing_url":"Enter a URL first.","workspaces.create.autofill_success":"Brand info loaded.","workspaces.create.autofill_error":"Could not autofill. You can fill the fields manually.","workspaces.create.autofill_errors.unreachable":"We could not reach that website (:reason).","workspaces.create.autofill_errors.http_status":"The website returned an unexpected status (:status).","workspaces.create.autofill_errors.invalid_scheme":"Only http and https URLs are supported.","workspaces.create.autofill_errors.missing_host":"The URL is missing a host.","workspaces.create.autofill_errors.unresolvable_host":"We could not resolve the host (:host).","workspaces.create.autofill_errors.private_network":"URLs pointing to private networks are not allowed.","workspaces.create.logo_captured":"Logo captured from your website.","workspaces.create.name":"Workspace name","workspaces.create.name_placeholder":"e.g. Acme Inc","workspaces.create.brand_description":"Brand description","workspaces.create.brand_description_placeholder":"What does your brand do?","workspaces.create.tone":"Brand tone","workspaces.create.tone_professional":"Professional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Friendly","workspaces.create.tone_bold":"Bold","workspaces.create.tone_inspirational":"Inspirational","workspaces.create.tone_humorous":"Humorous","workspaces.create.tone_educational":"Educational","workspaces.create.content_language":"Content language","workspaces.create.content_language_description":"AI-generated captions will be written in this language.","workspaces.create.voice_notes":"Voice notes (optional)","workspaces.create.voice_notes_placeholder":"e.g. short, punchy sentences. avoid jargon.","workspaces.create.brand_color":"Brand color","workspaces.create.background_color":"Background color","workspaces.create.text_color":"Text color","workspaces.create.submit":"Create workspace","workspaces.create.success":"Workspace created. Connect a social account to start posting.","workspaces.limit_reached":"You have reached your plan limit for workspaces.","workspaces.flash.deleted":"Workspace deleted successfully."} \ No newline at end of file diff --git a/lang/php_es.json b/lang/php_es.json index c2a4ed76..32d3a0dd 100644 --- a/lang/php_es.json +++ b/lang/php_es.json @@ -1 +1 @@ -{"accounts.title":"Conexiones","accounts.page_title":"Cuentas Sociales","accounts.description":"Resumen de todas tus cuentas sociales conectadas","accounts.add_social":"Agregar Red Social","accounts.add_social_title":"Conectar una Cuenta Social","accounts.add_social_description":"Conecta una cuenta social a TryPost para empezar a publicar","accounts.connect_cta":"Conectar","accounts.no_accounts":"No hay cuentas conectadas todavía","accounts.no_accounts_description":"Conecta tus redes sociales para empezar a programar y publicar posts","accounts.no_search_results":"Ninguna cuenta coincide con tu búsqueda","accounts.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","accounts.search":"Buscar cuentas...","accounts.added":"Agregada :date","accounts.limit_reached":"Has alcanzado el límite de cuentas sociales de tu plan.","accounts.not_connected":"No conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexión perdida","accounts.reconnect_account":"Reconectar cuenta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.table.account":"Cuenta","accounts.table.platform":"Plataforma","accounts.table.status":"Estado","accounts.table.last_used":"Último uso","accounts.table.added":"Añadida","accounts.table.active":"Activa","accounts.never_used":"Nunca usada","accounts.status.connected":"Conectada","accounts.status.disconnected":"Desconectada","accounts.descriptions.linkedin":"Conecta tu perfil personal de LinkedIn","accounts.descriptions.linkedin-page":"Conecta una página de empresa de LinkedIn","accounts.descriptions.x":"Conecta tu cuenta de X (Twitter)","accounts.descriptions.tiktok":"Conecta tu cuenta de TikTok","accounts.descriptions.youtube":"Conecta un canal de YouTube","accounts.descriptions.facebook":"Conecta una página de Facebook","accounts.descriptions.instagram":"Conecta una cuenta profesional de Instagram","accounts.descriptions.instagram-facebook":"Conecta Instagram vía página de Facebook","accounts.descriptions.threads":"Conecta tu cuenta de Threads","accounts.descriptions.pinterest":"Conecta tu cuenta de Pinterest","accounts.descriptions.bluesky":"Conecta tu cuenta de Bluesky","accounts.descriptions.mastodon":"Conecta tu cuenta de Mastodon","accounts.disconnect_modal.title":"Desconectar cuenta","accounts.disconnect_modal.description":"¿Estás seguro de que deseas desconectar esta cuenta? Puedes volver a conectarla en cualquier momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Introduce tus credenciales para conectar","accounts.bluesky.email":"Correo electrónico","accounts.bluesky.email_placeholder":"tuusuario.bsky.social","accounts.bluesky.app_password":"Contraseña de app","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Usa una Contraseña de App por seguridad. Crea una en bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Introduce tu instancia de Mastodon","accounts.mastodon.instance_url":"URL de la instancia","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Introduce la URL de tu instancia de Mastodon (ej: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar con Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Seleccionar página de Facebook","accounts.facebook.description":"Elige qué página deseas conectar","accounts.facebook.no_pages":"No se encontraron páginas","accounts.facebook.no_pages_description":"No eres administrador de ninguna página de Facebook.","accounts.facebook.page_label":"Página de Facebook","accounts.instagram_facebook.title":"Seleccionar cuenta de Instagram","accounts.instagram_facebook.description":"Elige qué cuenta de Instagram deseas conectar","accounts.instagram_facebook.no_pages":"No se encontraron cuentas de Instagram","accounts.instagram_facebook.no_pages_description":"No se encontraron páginas de Facebook con cuentas Instagram Business vinculadas.","accounts.linkedin.title":"Seleccionar página de LinkedIn","accounts.linkedin.description":"Elige qué página deseas conectar","accounts.linkedin.no_pages":"No se encontraron páginas","accounts.linkedin.no_pages_description":"No eres administrador de ninguna página de LinkedIn.","accounts.linkedin.page_label":"Página de LinkedIn","accounts.flash.disconnected":"¡Cuenta desconectada correctamente!","accounts.flash.connected":"¡Cuenta conectada correctamente!","accounts.flash.session_expired":"Sesión expirada. Inténtalo de nuevo.","accounts.flash.workspace_not_found":"Workspace no encontrado.","accounts.flash.activated":"¡Cuenta activada!","accounts.flash.deactivated":"¡Cuenta desactivada!","accounts.flash.already_connected":"Esta plataforma ya está conectada.","accounts.flash.no_youtube_channels":"No se encontraron canales de YouTube. Crea un canal primero.","accounts.popup_callback.title_success":"Conectado","accounts.popup_callback.title_error":"Error","accounts.popup_callback.closing":"Esta ventana se cerrará automáticamente...","accounts.popup_callback.close_now":"Puedes cerrar esta ventana ahora.","accounts.popup_callback.connected":"¡Cuenta conectada!","accounts.popup_callback.reconnected":"¡Cuenta reconectada!","accounts.popup_callback.error_connecting":"Error al conectar la cuenta. Inténtalo de nuevo.","accounts.popup_callback.error_connecting_page":"Error al conectar la página. Inténtalo de nuevo.","accounts.popup_callback.error_connecting_channel":"Error al conectar el canal. Inténtalo de nuevo.","accounts.popup_callback.session_expired":"Sesión expirada. Inténtalo de nuevo.","accounts.popup_callback.workspace_not_found":"Workspace no encontrado.","accounts.popup_callback.invalid_state":"Estado inválido. Inténtalo de nuevo.","accounts.popup_callback.failed_to_authenticate":"Falló la autenticación.","accounts.popup_callback.failed_to_get_profile":"Falló al obtener el perfil.","accounts.popup_callback.page_not_found":"Página no encontrada.","accounts.popup_callback.channel_not_found":"Canal no encontrado.","accounts.popup_callback.no_facebook_pages":"No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.","accounts.popup_callback.no_facebook_instagram_pages":"No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.","accounts.popup_callback.no_youtube_channels":"No se encontraron canales de YouTube. Crea un canal primero.","accounts.popup_callback.not_linkedin_admin":"No eres administrador de ninguna página de LinkedIn.","analytics.no_accounts":"No hay cuentas conectadas con analytics.","analytics.no_accounts_match":"Ninguna cuenta coincide.","analytics.search_account":"Buscar cuenta…","analytics.select_account":"Selecciona una cuenta para ver analytics.","analytics.no_data":"No hay datos de analytics disponibles.","analytics.metrics.avg_view_duration":"Duración Media (s)","analytics.metrics.avg_view_percentage":"Porcentaje Medio de Visualización","analytics.metrics.bookmarks":"Guardados","analytics.metrics.clicks":"Clics","analytics.metrics.comments":"Comentarios","analytics.metrics.engagement":"Engagement","analytics.metrics.favourites":"Favoritos","analytics.metrics.followers":"Seguidores","analytics.metrics.following":"Siguiendo","analytics.metrics.impressions":"Impresiones","analytics.metrics.interactions":"Interacciones","analytics.metrics.likes":"Me gusta","analytics.metrics.minutes_watched":"Minutos Vistos","analytics.metrics.organic_followers":"Seguidores Orgánicos","analytics.metrics.outbound_clicks":"Clics Externos","analytics.metrics.page_followers":"Seguidores de la Página","analytics.metrics.page_reach":"Alcance de la Página","analytics.metrics.page_views":"Vistas de la Página","analytics.metrics.paid_followers":"Seguidores Pagados","analytics.metrics.pin_click_rate":"Tasa de Clics en Pines","analytics.metrics.pin_clicks":"Clics en Pines","analytics.metrics.posts_engagement":"Engagement de Publicaciones","analytics.metrics.posts_reach":"Alcance de Publicaciones","analytics.metrics.quotes":"Citas","analytics.metrics.reach":"Alcance","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Comentarios Recientes","analytics.metrics.recent_likes":"Me Gusta Recientes","analytics.metrics.recent_shares":"Compartidos Recientes","analytics.metrics.replies":"Respuestas","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Guardados","analytics.metrics.shares":"Compartidos","analytics.metrics.subscribers_gained":"Suscriptores Ganados","analytics.metrics.subscribers_lost":"Suscriptores Perdidos","analytics.metrics.total_likes":"Total de Me gusta","analytics.metrics.video_views":"Vistas de Vídeo","analytics.metrics.videos":"Vídeos","analytics.metrics.views":"Vistas","assets.title":"Medios","assets.tabs.my_uploads":"Mis subidas","assets.tabs.stock_photos":"Fotos gratuitas","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Arrastra y suelta tus archivos aquí o haz clic para seleccionar","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Subiendo...","assets.empty.title":"Todavía no hay medios","assets.empty.description":"Sube imágenes y videos para construir tu biblioteca de medios.","assets.save_to_assets":"Guardar en la biblioteca","assets.saved":"¡Guardado en tu biblioteca!","assets.create_post":"Crear post","assets.add_to_post":"Agregar al post","assets.search_placeholder":"Buscar media...","assets.delete.title":"Eliminar medio","assets.delete.description":"¿Estás seguro de que deseas eliminar este medio? Esta acción no se puede deshacer.","assets.delete.confirm":"Eliminar","assets.delete.cancel":"Cancelar","assets.unsplash.search_placeholder":"Buscar fotos gratuitas...","assets.unsplash.no_results":"No se encontraron fotos","assets.unsplash.no_results_description":"Prueba con otro término de búsqueda.","assets.unsplash.trending":"Tendencias en Unsplash","assets.unsplash.start_searching":"Busca fotos gratuitas de Unsplash","assets.giphy.trending":"Tendencias en Giphy","assets.giphy.search_placeholder":"Buscar GIFs...","assets.giphy.no_results":"No se encontraron GIFs","assets.giphy.no_results_description":"Prueba con otro término de búsqueda.","assets.giphy.powered_by":"Powered by GIPHY","auth.failed":"Estas credenciales no coinciden con nuestros registros.","auth.password":"La contraseña proporcionada es incorrecta.","auth.throttle":"Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.","auth.flash.welcome":"¡Bienvenido a TryPost!","auth.flash.welcome_trial":"¡Bienvenido a TryPost! Tu prueba ha comenzado.","auth.legal":"Al continuar, aceptas nuestros Términos de Servicio y Política de Privacidad.","auth.slides.calendar.title":"Calendario Visual","auth.slides.calendar.description":"Planifica y programa tu contenido con un calendario intuitivo de arrastrar y soltar en todas tus cuentas sociales.","auth.slides.scheduling.title":"Programación Inteligente","auth.slides.scheduling.description":"Programa posts en LinkedIn, X, Instagram, TikTok, YouTube y más — todo desde un solo lugar.","auth.slides.media.title":"Contenido Multimedia","auth.slides.media.description":"Publica imágenes, carruseles, historias y reels. Cada plataforma recibe el formato correcto automáticamente.","auth.slides.video.title":"Publicación de Video","auth.slides.video.description":"Sube videos una vez y publícalos en TikTok, YouTube Shorts, Instagram Reels y Facebook Reels.","auth.slides.team.title":"Workspaces en Equipo","auth.slides.team.description":"Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.","auth.slides.signatures.title":"Firmas","auth.slides.signatures.description":"Guarda firmas reutilizables (hashtags, links, despedidas) y añádelas a tus posts con un clic.","auth.or_continue_with":"O continuar con","auth.google_login":"Iniciar sesión con Google","auth.google_signup":"Registrarse con Google","auth.github_login":"Iniciar sesión con GitHub","auth.github_signup":"Registrarse con GitHub","auth.github_email_unavailable":"No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.","auth.signup_success.page_title":"Bienvenido","auth.signup_success.title":"Configurando tu cuenta","auth.signup_success.description":"Esto suele tardar solo unos segundos...","auth.login.title":"Inicia sesión en tu cuenta","auth.login.description":"Introduce tu correo y contraseña para iniciar sesión","auth.login.page_title":"Iniciar sesión","auth.login.email":"Correo electrónico","auth.login.password":"Contraseña","auth.login.forgot_password":"¿Olvidaste tu contraseña?","auth.login.remember_me":"Recuérdame","auth.login.submit":"Iniciar sesión","auth.login.no_account":"¿No tienes una cuenta?","auth.login.sign_up":"Regístrate","auth.register.title":"Crear una cuenta","auth.register.description":"Introduce tus datos para crear tu cuenta","auth.register.page_title":"Registro","auth.register.name":"Nombre","auth.register.name_placeholder":"Nombre completo","auth.register.email":"Correo electrónico","auth.register.password":"Contraseña","auth.register.show_password":"Mostrar contraseña","auth.register.hide_password":"Ocultar contraseña","auth.register.submit":"Crear cuenta","auth.register.has_account":"¿Ya tienes una cuenta?","auth.register.log_in":"Iniciar sesión","auth.forgot_password.title":"Olvidé mi contraseña","auth.forgot_password.description":"Introduce tu correo para recibir un enlace de restablecimiento","auth.forgot_password.page_title":"Olvidé mi contraseña","auth.forgot_password.email":"Correo electrónico","auth.forgot_password.submit":"Enviar enlace de restablecimiento","auth.forgot_password.return_to":"O vuelve a","auth.forgot_password.log_in":"iniciar sesión","auth.reset_password.title":"Restablecer contraseña","auth.reset_password.description":"Introduce tu nueva contraseña","auth.reset_password.page_title":"Restablecer contraseña","auth.reset_password.email":"Correo electrónico","auth.reset_password.password":"Contraseña","auth.reset_password.confirm_password":"Confirmar contraseña","auth.reset_password.confirm_placeholder":"Confirmar contraseña","auth.reset_password.submit":"Restablecer contraseña","auth.verify_email.title":"Verificar correo","auth.verify_email.description":"Verifica tu correo electrónico haciendo clic en el enlace que acabamos de enviarte.","auth.verify_email.page_title":"Verificación de correo","auth.verify_email.link_sent":"Se ha enviado un nuevo enlace de verificación al correo electrónico proporcionado durante el registro.","auth.verify_email.resend":"Reenviar correo de verificación","auth.verify_email.log_out":"Cerrar sesión","auth.accept_invite.page_title":"Aceptar invitación","auth.accept_invite.title":"¡Has sido invitado!","auth.accept_invite.description":"Has sido invitado a unirte al workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Tu rol","auth.accept_invite.email":"Correo electrónico","auth.accept_invite.accept":"Aceptar invitación","auth.accept_invite.decline":"Rechazar invitación","auth.accept_invite.login_prompt":"Inicia sesión o crea una cuenta para aceptar esta invitación.","auth.accept_invite.log_in":"Iniciar sesión","auth.accept_invite.create_account":"Crear cuenta","billing.title":"Facturación","billing.upgrade_dialog.title":"Actualiza tu plan","billing.upgrade_dialog.description":"Elige un plan que se adapte a tus necesidades.","billing.upgrade_dialog.current_plan":"Plan actual","billing.upgrade_dialog.current_short":"Actual","billing.upgrade_dialog.current_badge":"Actual","billing.upgrade_dialog.subscribe":"Suscribirse","billing.upgrade_dialog.switch":"Cambiar a este plan","billing.upgrade_dialog.switch_short":"Cambiar","billing.upgrade_dialog.switch_to_yearly":"Cambiar a anual","billing.upgrade_dialog.switch_to_monthly":"Cambiar a mensual","billing.upgrade_dialog.unavailable":"No disponible","billing.upgrade_dialog.reasons.workspace_limit":"Has alcanzado el límite de workspaces de tu plan. Actualiza para crear más.","billing.upgrade_dialog.reasons.social_account_limit":"Has alcanzado el límite de cuentas sociales de tu plan. Actualiza para conectar más.","billing.upgrade_dialog.reasons.member_limit":"Has alcanzado el límite de miembros de tu plan. Actualiza para invitar a más personas.","billing.subscribe.page_title":"Elige tu plan","billing.subscribe.eyebrow":"Precios","billing.subscribe.title":"Elige el plan ideal para ti","billing.subscribe.description":"Elige el plan que te queda. Facturación mensual o anual.","billing.subscribe.monthly":"Mensual","billing.subscribe.yearly":"Anual","billing.subscribe.per_month":"mensual","billing.subscribe.per_year":"anual","billing.subscribe.billed_monthly":"Facturado mensualmente","billing.subscribe.billed_yearly":"Facturado anualmente","billing.subscribe.features_included":"Qué incluye:","billing.subscribe.everything_in":"Todo lo de :plan, más:","billing.subscribe.save_months":"2 meses gratis","billing.subscribe.popular":"Más popular","billing.subscribe.subscribe_cta":"Suscribirse","billing.subscribe.prices.starter.monthly":"$19","billing.subscribe.prices.starter.yearly_per_month":"$16","billing.subscribe.prices.starter.yearly":"$190","billing.subscribe.prices.plus.monthly":"$29","billing.subscribe.prices.plus.yearly_per_month":"$24","billing.subscribe.prices.plus.yearly":"$290","billing.subscribe.prices.pro.monthly":"$49","billing.subscribe.prices.pro.yearly_per_month":"$41","billing.subscribe.prices.pro.yearly":"$490","billing.subscribe.prices.max.monthly":"$99","billing.subscribe.prices.max.yearly_per_month":"$83","billing.subscribe.prices.max.yearly":"$990","billing.subscribe.features.social_accounts":":count cuentas sociales","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count miembros del equipo","billing.subscribe.features.credits":":count créditos IA/mes","billing.subscribe.credit_tooltips.starter":"En promedio 150 posts de largo medio + 5 imágenes IA por mes.","billing.subscribe.credit_tooltips.plus":"En promedio 300 posts de largo medio + 10 imágenes IA por mes.","billing.subscribe.credit_tooltips.pro":"En promedio 700 posts de largo medio + 30 imágenes IA por mes.","billing.subscribe.credit_tooltips.max":"En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.","billing.plan.title":"Plan","billing.plan.description":"Gestiona tu plan de suscripción.","billing.plan.change":"Cambiar plan","billing.plan.label":"Plan","billing.plan.price":"Precio","billing.plan.month":"mes","billing.plan.trial":"Prueba","billing.plan.active":"Activo","billing.plan.past_due":"Vencido","billing.plan.cancelling":"Cancelando","billing.plan.trial_ends":"La prueba termina en","billing.subscription.title":"Suscripción","billing.subscription.description":"Gestiona tu método de pago, datos de facturación y suscripción.","billing.subscription.payment_method":"Método de pago","billing.subscription.no_payment_method":"Aún no hay método de pago registrado.","billing.subscription.expires_on":"Vence el :month/:year","billing.subscription.manage_label":"Suscripción","billing.subscription.manage_stripe":"Gestionar en Stripe","billing.invoices.title":"Facturas","billing.invoices.description":"Descarga tus facturas anteriores.","billing.invoices.empty":"No se encontraron facturas","billing.invoices.paid":"Pagado","billing.flash.plan_changed":"Ahora estás en el plan :plan.","billing.flash.cannot_manage":"Solo el propietario de la cuenta puede gestionar la facturación.","billing.flash.cannot_downgrade.workspaces":"No puedes cambiar a :plan: tienes :count workspaces pero el plan solo permite :limit.","billing.flash.cannot_downgrade.social_accounts":"No puedes cambiar a :plan: tienes :count cuentas sociales pero el plan solo permite :limit.","billing.flash.cannot_downgrade.members":"No puedes cambiar a :plan: tienes :count miembros (incluyendo invitaciones) pero el plan solo permite :limit.","billing.flash.credits_exhausted":"Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.","billing.processing.page_title":"Procesando...","billing.processing.title":"Procesando tu suscripción","billing.processing.description":"Espera mientras configuramos tu cuenta. Solo tomará un momento.","billing.processing.success_title":"¡Todo listo!","billing.processing.success_description":"Tu suscripción está activa. Redirigiendo a tus workspaces...","billing.processing.cancelled_title":"Pago cancelado","billing.processing.cancelled_description":"Tu pago fue cancelado. No se realizaron cargos.","billing.processing.retry":"Intentar de nuevo","brands.new_brand":"Nueva Marca","brands.no_brands_yet":"No hay marcas todavía","brands.no_brands_description":"Crea marcas para organizar tus cuentas de redes sociales por cliente o proyecto","brands.accounts_count":":count cuentas","brands.create.title":"Crear Marca","brands.create.description":"Dale un nombre a tu marca para agrupar cuentas de redes sociales","brands.create.name":"Nombre de la Marca","brands.create.name_placeholder":"ej. Acme Corp, Personal","brands.create.submit":"Crear Marca","brands.create.submitting":"Creando...","brands.edit.title":"Editar Marca","brands.edit.description":"Actualiza el nombre de esta marca","brands.edit.name":"Nombre de la Marca","brands.edit.name_placeholder":"ej. Acme Corp, Personal","brands.edit.submit":"Guardar Cambios","brands.edit.submitting":"Guardando...","brands.delete.title":"Eliminar Marca","brands.delete.description":"¿Estás seguro de que deseas eliminar esta marca? Las cuentas de redes sociales se desasignarán pero no se eliminarán.","brands.delete.confirm":"Eliminar","brands.delete.cancel":"Cancelar","brands.flash.created":"¡Marca creada con éxito!","brands.flash.updated":"¡Marca actualizada con éxito!","brands.flash.deleted":"¡Marca eliminada con éxito!","calendar.title":"Calendario","calendar.today":"Hoy","calendar.day":"Día","calendar.week":"Semana","calendar.month":"Mes","calendar.new_post":"Nuevo post","calendar.no_content":"Sin contenido","calendar.more":"+:count más","comments.placeholder":"Escribe un comentario...","comments.reply_placeholder":"Escribe una respuesta...","comments.reply":"Responder","comments.edit":"Editar","comments.delete":"Eliminar","comments.edited":"editado","comments.save":"Guardar","comments.cancel":"Cancelar","comments.send":"Enviar","comments.replying_to":"Respondiendo a :name","comments.empty":"Todavía no hay comentarios. Inicia la conversación.","comments.load_more":"Cargar comentarios anteriores","comments.today":"Hoy","comments.yesterday":"Ayer","common.confirm_modal.cannot_be_undone":"Esta acción no se puede deshacer.","common.confirm_modal.type":"Escribe","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar al portapapeles","common.confirm_modal.delete_keyword":"eliminar","common.photo_upload.upload":"Subir","common.photo_upload.uploading":"Subiendo...","common.photo_upload.remove":"Eliminar foto","common.photo_upload.hint":"Recomendado: imagen cuadrada, máximo 2 MB.","common.timezone.select":"Seleccionar zona horaria","common.timezone.search":"Buscar zona horaria...","common.timezone.empty":"No se encontró zona horaria","common.date_picker.select":"Seleccionar fecha","common.date_range_picker.placeholder":"Elige un período","common.date_range_picker.today":"Hoy","common.date_range_picker.yesterday":"Ayer","common.date_range_picker.last_7_days":"Últimos 7 días","common.date_range_picker.last_30_days":"Últimos 30 días","common.date_range_picker.last_3_months":"Últimos 3 meses","common.date_range_picker.last_6_months":"Últimos 6 meses","common.date_range_picker.last_12_months":"Últimos 12 meses","common.date_range_picker.this_month":"Este mes","common.date_range_picker.last_month":"Mes pasado","common.date_range_picker.year_to_date":"Desde inicio del año","common.date_range_picker.last_year":"Año pasado","common.cancel":"Cancelar","common.clear":"Limpiar","common.close":"Cerrar","common.loading_more":"Cargando más...","labels.title":"Etiquetas","labels.description":"Crea etiquetas para organizar y categorizar tus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nueva etiqueta","labels.no_labels_yet":"Aún no hay etiquetas","labels.no_search_results":"Ninguna etiqueta coincide con tu búsqueda","labels.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","labels.create_first_label":"Crea tu primera etiqueta","labels.table.name":"Nombre","labels.table.created_at":"Creado","labels.actions.edit":"Editar etiqueta","labels.actions.delete":"Eliminar etiqueta","labels.create.title":"Crear etiqueta","labels.create.description":"Dale un nombre y elige un color para tu etiqueta","labels.create.name":"Nombre","labels.create.name_placeholder":"Nombre de la etiqueta...","labels.create.color":"Color","labels.create.submit":"Crear etiqueta","labels.create.submitting":"Creando...","labels.edit.title":"Editar etiqueta","labels.edit.description":"Actualiza el nombre y el color de esta etiqueta","labels.edit.name":"Nombre","labels.edit.name_placeholder":"Nombre de la etiqueta...","labels.edit.color":"Color","labels.edit.submit":"Guardar cambios","labels.edit.submitting":"Guardando...","labels.delete.title":"Eliminar etiqueta","labels.delete.description":"¿Estás seguro de que deseas eliminar esta etiqueta? Esta acción no se puede deshacer.","labels.delete.confirm":"Eliminar","labels.delete.cancel":"Cancelar","labels.flash.created":"¡Etiqueta creada correctamente!","labels.flash.updated":"¡Etiqueta actualizada correctamente!","labels.flash.deleted":"¡Etiqueta eliminada correctamente!","mail.mentioned.subject":":name te mencionó en TryPost","mail.mentioned.title":":name te mencionó","mail.mentioned.intro":":name te mencionó en un comentario.","mail.mentioned.cta":"Ver comentario","mail.workspace_connections_disconnected.subject":"{1} :count cuenta necesita ser reconectada en :workspace|[2,*] :count cuentas necesitan ser reconectadas en :workspace","mail.workspace_connections_disconnected.title":"Cuentas necesitan reconexión","mail.workspace_connections_disconnected.intro":"Las siguientes cuentas sociales en tu workspace :workspace se han desconectado y necesitan ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Esto puede haber ocurrido porque:","mail.workspace_connections_disconnected.reason_expired":"Los tokens de acceso expiraron","mail.workspace_connections_disconnected.reason_revoked":"Revocaste el acceso a TryPost en la plataforma","mail.workspace_connections_disconnected.reason_changed":"La plataforma cambió sus requisitos de autenticación","mail.workspace_connections_disconnected.reconnect_cta":"Reconecta estas cuentas para seguir programando y publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar cuentas","notifications.post_ready.title":"Tu publicación está lista","notifications.post_ready.body":"La IA terminó. Toca para revisar y publicar.","notifications.account_disconnected.title":"Cuenta de :platform desconectada","notifications.account_disconnected.body":":account necesita reconectarse","notifications.account_token_expired.title":"Cuenta de :platform necesita reconectarse","notifications.account_token_expired.body":"La sesión de :account expiró — reconéctala para seguir publicando","pagination.previous":"« Anterior","pagination.next":"Siguiente »","passwords.reset":"Tu contraseña ha sido restablecida.","passwords.sent":"Te hemos enviado un enlace para restablecer tu contraseña.","passwords.throttled":"Espera antes de intentarlo de nuevo.","passwords.token":"Este token de restablecimiento de contraseña no es válido.","passwords.user":"No encontramos un usuario con ese correo electrónico.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos los posts","posts.new_post":"Nuevo post","posts.no_posts":"No se encontraron posts","posts.no_search_results":"Ningún post coincide con tu búsqueda","posts.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","posts.start_creating":"Empieza creando tu primer post.","posts.filter_by_label":"Filtrar por etiqueta","posts.label_search_placeholder":"Buscar etiquetas...","posts.no_labels":"No se encontraron etiquetas.","posts.clear_label_filter":"Limpiar filtro de etiquetas","posts.table.post":"Post","posts.table.status":"Estado","posts.table.content":"Contenido","posts.table.platforms":"Plataformas","posts.table.labels":"Etiquetas","posts.table.scheduled_at":"Fecha","posts.table.actions":"","posts.manage_posts":"Administra todos tus posts","posts.delete_confirm":"¿Estás seguro de que deseas eliminar este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Eliminar","posts.actions.duplicate":"Duplicar","posts.actions.copy_id":"Copiar ID","posts.actions.copied":"ID copiado al portapapeles","posts.form.post_type":"Tipo de post","posts.form.board":"Tablero","posts.form.select_board":"Seleccionar tablero","posts.form.search_board":"Buscar tablero...","posts.form.no_board_found":"No se encontró tablero","posts.form.media":"Multimedia","posts.form.min":"Min","posts.form.uploading":"Subiendo...","posts.form.drop_to_upload":"Suelta para subir","posts.form.drag_and_drop":"Arrastra y suelta o haz clic para subir","posts.form.photos_and_videos":"Fotos y videos","posts.form.photos_only":"Solo fotos","posts.form.videos_only":"Solo videos","posts.form.drag_to_reorder":"Arrastra para reordenar","posts.form.caption":"Descripción","posts.form.write_caption":"Escribe tu descripción...","posts.form.content_exceeds_platform":":platform: demasiado largo por :over caracteres (máx :limit).","posts.form.tiktok.settings":"Configuración de TikTok","posts.form.tiktok.variant_label":"Tipo de publicación","posts.form.tiktok.variant.video":"Video","posts.form.tiktok.variant.photo":"Carrusel de fotos","posts.form.tiktok.posting_to":"Publicando en","posts.form.tiktok.privacy_level":"¿Quién puede ver este video?","posts.form.tiktok.privacy_placeholder":"Selecciona la visibilidad","posts.form.tiktok.privacy.public":"Público para todos","posts.form.tiktok.privacy.friends":"Amigos mutuos","posts.form.tiktok.privacy.followers":"Seguidores","posts.form.tiktok.privacy.private":"Solo yo","posts.form.tiktok.privacy.private_disabled_branded":"El contenido de marca no puede ser privado.","posts.form.tiktok.privacy_hint":"Las opciones disponibles dependen de la configuración de tu cuenta de TikTok.","posts.form.tiktok.auto_add_music":"Agregar música automáticamente","posts.form.tiktok.auto_add_music_hint":"Disponible solo para fotos. Agrega una música predeterminada que puedes cambiar después.","posts.form.tiktok.yes":"Sí","posts.form.tiktok.no":"No","posts.form.tiktok.allow_users":"Permitir a los usuarios:","posts.form.tiktok.comments":"Comentar","posts.form.tiktok.duet":"Dueto","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Video hecho con IA","posts.form.tiktok.disclose":"Divulgar contenido del video","posts.form.tiktok.disclose_hint":"Activa para divulgar que este video promueve bienes o servicios a cambio de algo de valor. Tu video puede promocionarte a ti, a un tercero o ambos.","posts.form.tiktok.promotional_organic_title":"Tu foto/video será etiquetado como \"Contenido Promocional\".","posts.form.tiktok.promotional_paid_title":"Tu foto/video será etiquetado como \"Asociación pagada\".","posts.form.tiktok.promotional_description":"Esto no se puede cambiar una vez publicado el video.","posts.form.tiktok.compliance_incomplete":"Debes indicar si tu contenido promociona a ti mismo, a un tercero o a ambos.","posts.form.tiktok.privacy_required":"La visibilidad de TikTok es obligatoria al publicar.","posts.form.tiktok.branded_cleared_private":"La visibilidad se borró porque el contenido de marca no puede ser privado.","posts.form.tiktok.interaction_disabled_by_creator":"Desactivado por la configuración de tu cuenta TikTok.","posts.form.tiktok.max_duration_exceeded":"El video dura :duration s pero esta cuenta solo permite videos de hasta :max s.","posts.form.tiktok.processing_hint":"Después de publicar, puede tardar unos minutos en procesarse y aparecer en tu perfil de TikTok.","posts.form.tiktok.brand_organic":"Tu marca","posts.form.tiktok.brand_organic_hint":"Estás promocionándote a ti mismo o a tu propia marca. Este video será clasificado como Brand Organic.","posts.form.tiktok.brand_content":"Contenido patrocinado","posts.form.tiktok.brand_content_hint":"Estás promocionando otra marca o a un tercero. Este video será clasificado como Branded Content.","posts.form.tiktok.compliance.agree":"Al publicar, aceptas la","posts.form.tiktok.compliance.music_usage":"Confirmación de Uso de Música","posts.form.tiktok.compliance.and":"y la","posts.form.tiktok.compliance.branded_policy":"Política de Contenido Patrocinado","posts.form.instagram.settings":"Configuración de Instagram","posts.form.instagram.posting_to":"Publicando en","posts.form.instagram.variant_label":"Tipo de publicación","posts.form.instagram.variant.feed":"Publicación","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Historia","posts.form.instagram.aspect_label":"Proporción","posts.form.instagram.aspect.square":"Cuadrado (1:1)","posts.form.instagram.aspect.portrait":"Vertical (4:5)","posts.form.instagram.aspect.landscape":"Horizontal (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Configuración de Facebook","posts.form.facebook.posting_to":"Publicando en","posts.form.facebook.variant_label":"Tipo de publicación","posts.form.facebook.variant.post":"Publicación","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Historia","posts.form.linkedin.settings":"Configuración de LinkedIn","posts.form.linkedin.settings_page":"Configuración de la Página de LinkedIn","posts.form.linkedin.posting_to":"Publicando en","posts.form.linkedin.variant_label":"Tipo de publicación","posts.form.linkedin.variant.post":"Publicación","posts.form.linkedin.variant.carousel":"Carrusel","posts.form.pinterest.settings":"Configuración de Pinterest","posts.form.pinterest.posting_to":"Publicando en","posts.form.pinterest.variant_label":"Tipo de pin","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carrusel","posts.form.pinterest.board":"Tablero","posts.form.pinterest.select_board":"Selecciona un tablero","posts.form.pinterest.no_boards":"No se encontraron tableros de Pinterest. Crea uno en tu cuenta de Pinterest primero.","posts.form.pinterest.search_board":"Buscar tableros...","posts.form.pinterest.no_board_found":"Ningún tablero coincide con tu búsqueda.","posts.form.pinterest.board_required":"Selecciona un tablero de Pinterest para publicar este post.","posts.form.warnings.no_variant":"Elige un tipo de publicación para continuar.","posts.form.warnings.requires_media":"Este tipo requiere al menos una imagen o video.","posts.form.warnings.max_files_exceeded":"Este tipo acepta hasta :max archivos (tienes :current).","posts.form.warnings.min_files_required":"Este tipo requiere al menos :min archivos (tienes :current).","posts.form.warnings.no_video_allowed":"Este tipo no acepta videos.","posts.form.warnings.no_image_allowed":"Este tipo acepta solo videos.","posts.form.warnings.gif_not_allowed":"Esta red no acepta GIF. Elimínalo o selecciona otra red.","posts.form.warnings.image_too_large":"La imagen supera el límite de :max (la tuya es :current).","posts.form.warnings.video_too_large":"El video supera el límite de :max (el tuyo es :current).","posts.form.warnings.video_too_long":"El video dura :current, pero este tipo permite hasta :max.","posts.form.warnings.aspect_ratio_too_narrow":"La proporción :current es demasiado alta (mínimo :min).","posts.form.warnings.aspect_ratio_too_wide":"La proporción :current es demasiado ancha (máximo :max).","posts.status.pending":"Pendiente","posts.status.draft":"Borrador","posts.status.scheduled":"Programado","posts.status.publishing":"Publicando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente publicado","posts.status.failed":"Fallido","posts.descriptions.draft":"Posts esperando ser programados","posts.descriptions.scheduled":"Posts programados para publicar","posts.descriptions.published":"Posts ya publicados","posts.ai.generate.button_tooltip":"Generar con IA","posts.ai.generate.title":"Generar post con IA","posts.ai.generate.description":"Describe sobre qué debe ser el post. La IA usará el contexto de tu marca para escribirlo.","posts.ai.generate.prompt_label":"¿De qué trata este post?","posts.ai.generate.prompt_placeholder":"ej: anunciar nuestra nueva función de generación de imágenes para carruseles","posts.ai.generate.preview_label":"Vista previa","posts.ai.generate.start":"Generar","posts.ai.generate.apply":"Usar este contenido","posts.ai.generate.retry":"Intentar de nuevo","posts.ai.generate.cancel":"Cancelar","posts.ai.review.button_tooltip":"Revisar con IA","posts.ai.review.title":"Revisar post con IA","posts.ai.review.description":"La IA analiza gramática, ortografía y claridad. Aplica las sugerencias con las que estés de acuerdo.","posts.ai.review.loading":"Revisando tu texto...","posts.ai.review.no_issues":"No se encontraron problemas. Todo bien.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Sugerencia","posts.ai.review.apply":"Aplicar","posts.ai.review.apply_all":"Aplicar todas","posts.ai.review.applied":"Aplicada","posts.ai.review.cancel":"Cancelar","posts.show.title":"Detalles del post","posts.show.edit":"Editar","posts.show.back":"Volver","posts.show.no_content":"Sin texto","posts.show.platforms":"Plataformas","posts.show.no_platforms":"Ninguna plataforma seleccionada.","posts.show.view_on_platform":"Ver en la plataforma","posts.show.published_on":"Publicado el :date","posts.show.scheduled_for":"Programado para el :date","posts.show.draft":"Borrador","posts.show.status_pending":"Pendiente","posts.show.metrics":"Métricas","posts.show.metrics_loading":"Cargando métricas…","posts.show.metrics_unavailable":"Métricas aún no disponibles para esta plataforma.","posts.show.metrics_empty":"No se devolvieron métricas.","posts.edit.title":"Editar post","posts.edit.view_title":"Ver post","posts.edit.labels":"Etiquetas","posts.edit.signatures":"Firmas","posts.edit.schedule":"Programar","posts.edit.delete":"Eliminar","posts.edit.schedule_for":"Programar para","posts.edit.unschedule":"Desprogramar","posts.edit.saving":"Guardando...","posts.edit.saved":"Guardado","posts.edit.draft":"Borrador","posts.edit.media":"Multimedia","posts.edit.add_media":"Añadir media","posts.edit.caption":"Descripción","posts.edit.caption_placeholder":"Escribe tu descripción...","posts.edit.compose_title":"Crear un post","posts.edit.compose_subtitle":"Compón tu mensaje y agrega media","posts.edit.preview_empty.title":"Ninguna plataforma seleccionada","posts.edit.preview_empty.description":"Selecciona una plataforma para publicar y ver la vista previa.","posts.edit.drop_zone_title":"Añadir media","posts.edit.drop_zone_subtitle":"Arrastra archivos o haz clic para seleccionar","posts.edit.add":"Añadir","posts.edit.publish_to":"Publicar en","posts.edit.organize":"Organizar","posts.edit.no_labels":"Todavía no hay etiquetas creadas","posts.edit.pick_time":"Elegir hora","posts.edit.post_now":"Publicar ahora","posts.edit.time":"Hora","posts.edit.cancel":"Cancelar","posts.edit.schedule_date":"Fecha de programación","posts.edit.view_on_platform":"Ver en la plataforma","posts.edit.platform_status":"Estado de la plataforma","posts.edit.compliance_incomplete":"Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.","posts.edit.compliance.requires_media":"Agrega una imagen o video para publicar aquí.","posts.edit.compliance.too_many_files":"Solo se permiten :max archivo(s) en este formato.","posts.edit.compliance.too_few_files":"Agrega al menos :min archivos para este formato.","posts.edit.compliance.no_videos":"Solo se permiten imágenes en este formato.","posts.edit.compliance.no_images":"Solo se permiten videos en este formato.","posts.edit.compliance.no_gifs":"Los GIFs no son compatibles aquí.","posts.edit.compliance.video_too_large":"El video supera el límite de tamaño de esta plataforma.","posts.edit.compliance.video_too_long":"El video debe durar menos de :seconds segundos en este formato.","posts.edit.compliance.image_too_large":"La imagen supera el límite de tamaño de esta plataforma.","posts.edit.compliance.aspect_ratio_invalid":"La proporción de aspecto no es compatible con este formato.","posts.edit.compliance.no_content_type":"Elige un tipo de contenido para esta plataforma.","posts.edit.publishing":"Publicando...","posts.edit.publishing_overlay_title":"Tu publicación se está enviando","posts.edit.publishing_overlay_subtitle":"Esto puede tardar unos momentos. Puedes salir de esta página sin problemas.","posts.edit.scheduled_overlay_title":"Esta publicación está programada","posts.edit.scheduled_overlay_subtitle":"Programada para :date. Cancela la programación para hacer cambios.","posts.edit.unschedule_cta":"Cancelar para editar","posts.edit.tabs.preview":"Vista previa","posts.edit.tabs.schedule":"Programación","posts.edit.tabs.comments":"Comentarios","posts.edit.tabs.comments_empty":"Todavía no hay comentarios.","posts.edit.media_picker.title":"Elegir de la galería","posts.edit.media_picker.search":"Buscar media...","posts.edit.media_picker.empty":"Aún no hay archivos en tu galería","posts.edit.media_picker.cancel":"Cancelar","posts.edit.media_picker.add":"Agregar","posts.edit.media_picker.add_count":"Agregar :count","posts.edit.emoji_picker.search":"Buscar emoji","posts.edit.emoji_picker.empty":"No se encontraron emojis","posts.edit.emoji_picker.recent":"Usados con frecuencia","posts.edit.emoji_picker.smileys":"Caritas y emociones","posts.edit.emoji_picker.people":"Personas y cuerpo","posts.edit.emoji_picker.nature":"Animales y naturaleza","posts.edit.emoji_picker.food":"Comida y bebida","posts.edit.emoji_picker.activities":"Actividades","posts.edit.emoji_picker.travel":"Viajes y lugares","posts.edit.emoji_picker.objects":"Objetos","posts.edit.emoji_picker.symbols":"Símbolos","posts.edit.emoji_picker.flags":"Banderas","posts.edit.status.scheduled":"Programado","posts.edit.status.published":"Publicado","posts.edit.status.publishing":"Publicando...","posts.edit.status.failed":"Fallido","posts.edit.delete_modal.title":"Eliminar post","posts.edit.delete_modal.description":"¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.","posts.edit.delete_modal.action":"Eliminar","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"¿Activar sincronización?","posts.edit.sync_enable.description":"Todas las plataformas compartirán el mismo contenido. Las ediciones personalizadas realizadas en plataformas individuales serán reemplazadas con el contenido actual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Activar sincronización","posts.edit.sync_disable.title":"¿Desactivar sincronización?","posts.edit.sync_disable.description":"Cada plataforma mantendrá su contenido actual, pero las ediciones futuras solo se aplicarán a la plataforma que estés editando.","posts.edit.sync_disable.customize_note":"Podrás personalizar el contenido de cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desactivar sincronización","posts.edit.platforms_dialog.title":"Seleccionar plataformas","posts.edit.platforms_dialog.description":"Elige en qué plataformas publicar este post.","posts.edit.signatures_modal.search":"Buscar firmas...","posts.edit.signatures_modal.no_results":"No se encontraron firmas.","posts.edit.validation.select_board":"Selecciona un tablero","posts.edit.validation.images_not_supported":"Imágenes no soportadas","posts.edit.validation.videos_not_supported":"Videos no soportados","posts.edit.validation.max_images":"Máximo :count imágenes","posts.edit.validation.requires_media":"Requiere multimedia","posts.edit.validation.requires_content":"Se requiere texto","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform no soporta imágenes","posts.edit.validation.supports_up_to_images":":platform soporta hasta :count imágenes","posts.edit.validation.does_not_support_videos":":platform no soporta videos","posts.content_types.instagram_feed.label":"Post del feed","posts.content_types.instagram_feed.description":"Aparece en tu feed y perfil","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Video corto de hasta 90 segundos","posts.content_types.instagram_story.label":"Historia","posts.content_types.instagram_story.description":"Desaparece después de 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_carousel.label":"Carrusel","posts.content_types.linkedin_carousel.description":"Imágenes deslizables","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_page_carousel.label":"Carrusel","posts.content_types.linkedin_page_carousel.description":"Imágenes deslizables","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post estándar en tu página","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Video corto de hasta 90 segundos","posts.content_types.facebook_story.label":"Historia","posts.content_types.facebook_story.description":"Desaparece después de 24 horas","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Contenido de video corto","posts.content_types.tiktok_photo.label":"Carrusel de fotos","posts.content_types.tiktok_photo.description":"Hasta 35 fotos en un carrusel deslizable","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Video vertical de hasta 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet con texto y multimedia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto con multimedia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagen estándar","posts.content_types.pinterest_video_pin.label":"Pin de video","posts.content_types.pinterest_video_pin.description":"Pin de video (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carrusel","posts.content_types.pinterest_carousel.description":"Carrusel multi-imagen (2-5 imágenes)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto con imágenes opcionales","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto con multimedia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página de LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página de Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"¡Post programado correctamente!","posts.flash.deleted":"¡Post eliminado correctamente!","posts.flash.duplicated":"Post duplicado como borrador.","posts.flash.cannot_edit_published":"Los posts publicados no se pueden editar.","posts.flash.cannot_delete_published":"Los posts publicados no se pueden eliminar.","posts.flash.connect_first":"Conecta al menos una red social antes de crear un post.","posts.errors.account_disconnected":"Cuenta social desconectada","posts.errors.account_inactive":"Cuenta social desactivada","posts.errors.account_token_expired":"Sesión de la cuenta social expirada — reconecta la cuenta","posts.delete.title":"¿Eliminar post?","posts.delete.description":"Esta acción no se puede deshacer. El post y todos sus archivos multimedia se eliminarán de forma permanente.","posts.delete.confirm":"Sí, eliminar","posts.delete.cancel":"Cancelar","posts.create.title":"Crear nuevo post","posts.create.description":"Elige cómo quieres empezar.","posts.create.scratch_title":"Empezar desde cero","posts.create.scratch_description":"Abre un post en blanco para escribirlo todo.","posts.create.ai_title":"Generar con IA","posts.create.ai_description":"Describe lo que quieres y la IA genera el contenido por ti.","posts.create.ai_configure_description":"Elige un formato y describe el post que quieres crear.","posts.create.template_title":"Usar una plantilla","posts.create.template_description":"Elige una de nuestras plantillas y personalízala.","posts.create.preview.image_title":"Título de la imagen","posts.create.preview.image_body":"Texto de la imagen","posts.create.coming_soon":"Próximamente","posts.create.steps.format_title":"Elige un formato","posts.create.steps.format_description":"Selecciona el tipo de post que quieres crear.","posts.create.steps.account_title":"Elige una cuenta","posts.create.steps.account_description":"Selecciona la cuenta social donde publicar.","posts.create.steps.media_title":"Opciones de medios","posts.create.steps.media_carousel":"¿Cuántas diapositivas?","posts.create.steps.media_optional":"¿Incluir imágenes?","posts.create.steps.media_optional_label":"¿Cuántas imágenes?","posts.create.steps.media_none":"Ninguna","posts.create.steps.media_count_label":"Número de imágenes","posts.create.steps.prompt_title":"Describe tu post","posts.create.steps.prompt_label":"¿De qué trata este post?","posts.create.steps.prompt_placeholder":"Ej. Anuncia nuestra nueva función de carrusel para Instagram","posts.create.steps.preview_error":"Algo salió mal. Por favor, inténtalo de nuevo.","posts.create.steps.loading_page_title":"Generando tu publicación","posts.create.steps.loading_eta":"Tiempo estimado: cerca de :minutes.","posts.create.steps.loading_eta_minute_one":"1 minuto","posts.create.steps.loading_eta_minute_other":":count minutos","posts.create.steps.loading_leave_title":"Puedes seguir trabajando.","posts.create.steps.loading_leave_body":"Te avisamos cuando la publicación esté lista.","posts.create.steps.loading_leave_cta":"Ir al calendario","posts.create.steps.loading_create_another_cta":"Crear otra publicación","posts.create.steps.loading_tip_credits":"Cada imagen IA usa unos 15 créditos.","posts.create.steps.loading_tip_edit":"Podrás editar todo cuando la publicación esté lista.","posts.create.steps.loading_tip_draft":"Las publicaciones generadas van directo a tus borradores.","posts.create.steps.loading_tip_brand":"Ajusta tu marca para influir en las próximas publicaciones.","posts.create.steps.loading_tip_carousel":"Los carruseles generan una diapositiva por imagen solicitada.","posts.create.steps.loading_tip_quality":"La calidad balancea velocidad y costo.","posts.create.steps.create":"Crear post","posts.create.steps.back":"Atrás","posts.create.steps.next":"Continuar","posts.create.steps.cancel":"Cancelar","posts.create.steps.discard":"Descartar","posts.create.steps.retry":"Intentar de nuevo","posts.create.steps.no_platforms":"Sin cuentas conectadas","posts.create.steps.connect_first":"Conecta al menos una cuenta social para usar la generación con IA.","posts.create.steps.format.instagram_feed":"Post de Feed de Instagram","posts.create.steps.format.instagram_carousel":"Carrusel de Instagram","posts.create.steps.format.linkedin_post":"Post de LinkedIn","posts.create.steps.format.linkedin_page_post":"Post de Página de LinkedIn","posts.create.steps.format.x_post":"Post en X","posts.create.steps.format.bluesky_post":"Post en Bluesky","posts.create.steps.format.threads_post":"Post en Threads","posts.create.steps.format.mastodon_post":"Post en Mastodon","posts.create.steps.format.facebook_post":"Post en Facebook","posts.create.steps.format.pinterest_pin":"Pin de Pinterest","posts.create.steps.format.instagram_story":"Story de Instagram","posts.create.steps.format.facebook_story":"Story de Facebook","posts.templates.browser_title":"Elige una plantilla","posts.templates.browser_description":"Comienza con una plantilla curada y adáptala.","posts.templates.search_placeholder":"Buscar plantillas…","posts.templates.no_search_results":"Ninguna plantilla coincide con tu búsqueda","posts.templates.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.all_platforms":"Todas las plataformas","posts.templates.platform_search_placeholder":"Buscar plataforma…","posts.templates.no_platform_match":"Ninguna plataforma coincide.","posts.templates.use_this":"Usar esta plantilla","posts.templates.no_templates":"No hay plantillas disponibles.","posts.templates.applying":"Aplicando plantilla…","posts.templates.category.product_launch":"Lanzamiento de producto","posts.templates.category.promotion":"Promoción","posts.templates.category.educational":"Educativo","posts.templates.category.behind_the_scenes":"Detrás de cámaras","posts.templates.category.testimonial":"Testimonio","posts.templates.category.industry_tip":"Consejo del sector","posts.templates.category.event":"Evento","posts.templates.category.engagement":"Interacción","settings.title":"Configuración","settings.description":"Administra tu perfil y configuración de la cuenta","settings.hub.title":"Configuración","settings.hub.description":"Elige qué quieres gestionar.","settings.hub.profile.title":"Perfil","settings.hub.profile.description":"Actualiza tu información personal, contraseña y preferencias de notificaciones.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configura tu workspace, marca, miembros y claves de API.","settings.hub.account.title":"Cuenta","settings.hub.account.description":"Gestiona la información de la cuenta, uso y facturación.","settings.nav.profile":"Perfil","settings.nav.authentication":"Autenticación","settings.nav.workspace":"Workspace","settings.nav.members":"Miembros","settings.nav.notifications":"Notificaciones","settings.nav.billing":"Facturación","settings.notifications.title":"Preferencias de notificaciones","settings.notifications.heading":"Notificaciones por correo","settings.notifications.description":"Elige qué notificaciones por correo deseas recibir","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Recibir un correo cuando tu post se publique correctamente","settings.notifications.post_failed":"Post fallido","settings.notifications.post_failed_description":"Recibir un correo cuando tu post falle al publicar","settings.notifications.account_disconnected":"Cuenta desconectada","settings.notifications.account_disconnected_description":"Recibir un correo cuando una cuenta social se desconecte","settings.notifications.save":"Guardar preferencias","settings.profile.title":"Configuración del perfil","settings.profile.photo_heading":"Foto de perfil","settings.profile.photo_description":"Sube una foto de perfil","settings.profile.heading":"Información del perfil","settings.profile.description":"Actualiza tu nombre y correo electrónico","settings.profile.avatar":"Avatar","settings.profile.name":"Nombre","settings.profile.name_placeholder":"Nombre completo","settings.profile.email":"Correo electrónico","settings.profile.email_placeholder":"Correo electrónico","settings.profile.email_unverified":"Tu correo electrónico no ha sido verificado.","settings.profile.resend_verification":"Haz clic aquí para reenviar el correo de verificación.","settings.profile.verification_sent":"Se ha enviado un nuevo enlace de verificación a tu correo electrónico.","settings.profile.save":"Guardar","settings.authentication.title":"Autenticación","settings.authentication.page_title":"Configuración de autenticación","settings.authentication.sessions.title":"Sesiones activas","settings.authentication.sessions.description":"Si notas algo sospechoso, cierra sesión en otros dispositivos.","settings.authentication.sessions.unknown_browser":"Navegador desconocido","settings.authentication.sessions.unknown_ip":"IP desconocida","settings.authentication.sessions.on":"en","settings.authentication.sessions.active_now":"Activa ahora","settings.authentication.sessions.log_out_others":"Cerrar otras sesiones","settings.authentication.sessions.modal_title":"Cerrar otras sesiones","settings.authentication.sessions.modal_description_password":"Introduce tu contraseña actual para confirmar el cierre de las demás sesiones.","settings.authentication.sessions.modal_description_email":"Escribe tu correo electrónico para confirmar el cierre de las demás sesiones.","settings.authentication.sessions.password_placeholder":"Contraseña actual","settings.authentication.sessions.email_placeholder":"Tu correo","settings.authentication.sessions.cancel":"Cancelar","settings.authentication.sessions.submit":"Cerrar otras sesiones","settings.authentication.sessions.email_mismatch":"El correo electrónico no coincide con tu cuenta.","settings.authentication.sessions.flash_logged_out":"Has cerrado sesión en los demás dispositivos.","settings.authentication.password.update_title":"Actualizar contraseña","settings.authentication.password.set_title":"Definir una contraseña","settings.authentication.password.update_description":"Asegúrate de usar una contraseña larga y aleatoria para mantener tu cuenta segura.","settings.authentication.password.set_description":"Añade una contraseña para iniciar sesión sin un proveedor conectado.","settings.authentication.password.current_password":"Contraseña actual","settings.authentication.password.new_password":"Nueva contraseña","settings.authentication.password.confirm_password":"Confirmar contraseña","settings.authentication.password.save":"Guardar contraseña","settings.authentication.password.set":"Definir contraseña","settings.authentication.providers.title":"Cuentas conectadas","settings.authentication.providers.description":"Inicia sesión más rápido con estos proveedores conectados.","settings.authentication.providers.connected":"Conectada","settings.authentication.providers.not_connected":"No conectada","settings.authentication.providers.connect":"Conectar","settings.authentication.providers.disconnect":"Desconectar","settings.authentication.providers.flash_disconnected":":provider desconectada correctamente.","settings.authentication.providers.flash_connected":":provider conectada correctamente.","settings.authentication.providers.flash_already_linked":"Esa cuenta de :provider ya está vinculada a otro usuario.","settings.authentication.providers.flash_cannot_disconnect":"No puedes desconectar tu único método de inicio de sesión. Define una contraseña o conecta otro proveedor primero.","settings.delete_account.heading":"Eliminar cuenta","settings.delete_account.description":"Elimina tu cuenta y todos sus recursos","settings.delete_account.warning":"Advertencia","settings.delete_account.warning_message":"Procede con precaución, esta acción no se puede deshacer.","settings.delete_account.button":"Eliminar cuenta","settings.delete_account.modal_title":"¿Estás seguro de que deseas eliminar tu cuenta?","settings.delete_account.modal_description_password":"Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar.","settings.delete_account.modal_description_email":"Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Escribe tu correo :email para confirmar.","settings.delete_account.password":"Contraseña","settings.delete_account.password_placeholder":"Contraseña","settings.delete_account.email_placeholder":"Tu correo","settings.delete_account.email_mismatch":"El correo electrónico no coincide con tu cuenta.","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Eliminar cuenta","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Marca","settings.workspace.tabs.users":"Miembros","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Configuración del workspace","settings.workspace.logo_heading":"Logo del workspace","settings.workspace.logo_description":"Sube un logo para tu workspace","settings.workspace.heading":"Nombre del workspace","settings.workspace.description":"Actualiza el nombre del workspace","settings.workspace.members_heading":"Miembros","settings.workspace.members_description":"Administra miembros e invitaciones del workspace","settings.workspace.name":"Nombre","settings.workspace.name_placeholder":"Mi Workspace","settings.workspace.save":"Guardar","settings.brand.title":"Marca","settings.brand.description":"Configura la identidad de tu marca para el contenido generado por IA.","settings.brand.name":"Nombre del workspace","settings.brand.name_placeholder":"Mi marca","settings.brand.website":"Sitio web","settings.brand.website_placeholder":"https://tumarca.com","settings.brand.brand_description":"Descripción","settings.brand.brand_description_placeholder":"Cuéntanos sobre tu marca, lo que haces y quién es tu audiencia...","settings.brand.tone":"Tono de voz","settings.brand.tone_professional":"Profesional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Amigable","settings.brand.tone_bold":"Audaz","settings.brand.tone_inspirational":"Inspirador","settings.brand.tone_humorous":"Humorístico","settings.brand.tone_educational":"Educativo","settings.brand.voice_notes":"Notas de voz","settings.brand.voice_notes_placeholder":"Directrices adicionales de escritura, palabras a evitar, preferencias de estilo...","settings.brand.brand_color":"Color de marca","settings.brand.background_color":"Color de fondo","settings.brand.text_color":"Color de texto","settings.brand.font":"Fuente","settings.brand.image_style":"Estilo de imágenes","settings.brand.image_style_description":"Estilo visual aplicado al generar imágenes de diapositivas y portadas para publicaciones con IA.","settings.brand.image_style_cinematic":"Cinematográfico","settings.brand.image_style_illustration":"Ilustración","settings.brand.image_style_isometric_3d":"Isométrico","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Tipográfico","settings.brand.image_style_infographic":"Infográfico","settings.brand.image_style_minimalist":"Minimalista","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Idioma del contenido","settings.brand.content_language_description":"Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.","settings.members.title":"Miembros","settings.members.heading":"Miembros del equipo","settings.members.description":"Administra miembros e invitaciones de este workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Eliminar","settings.members.make_admin":"Hacer administrador","settings.members.make_member":"Hacer miembro","settings.members.invite.title":"Invitar miembro","settings.members.invite.description":"Envía una invitación por correo para agregar colaboradores","settings.members.invite.email":"Correo electrónico","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Rol","settings.members.invite.role_placeholder":"Selecciona un rol","settings.members.invite.submit":"Enviar invitación","settings.members.pending.title":"Invitaciones pendientes","settings.members.pending.description":"Invitaciones en espera de aceptación","settings.members.pending.empty":"No hay invitaciones pendientes","settings.members.list.title":"Miembros","settings.members.list.description":"Personas con acceso a este workspace","settings.members.list.empty":"No hay miembros además del propietario","settings.members.remove_modal.title":"Eliminar miembro","settings.members.remove_modal.description":"¿Estás seguro de que deseas eliminar a este miembro del workspace? Perderá acceso a todos los recursos del workspace.","settings.members.remove_modal.action":"Eliminar miembro","settings.members.cancel_invite_modal.title":"Cancelar invitación","settings.members.cancel_invite_modal.description":"¿Estás seguro de que deseas cancelar esta invitación?","settings.members.cancel_invite_modal.action":"Cancelar invitación","settings.members.roles.owner":"Propietario","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Miembro","settings.members.roles.viewer":"Espectador","settings.members.flash.invite_sent":"¡Invitación enviada correctamente!","settings.members.flash.invite_deleted":"Invitación eliminada.","settings.members.flash.member_removed":"¡Miembro eliminado correctamente!","settings.members.flash.role_updated":"Rol del miembro actualizado.","settings.members.flash.wrong_email":"Esta invitación es para otro correo electrónico.","settings.members.flash.already_member":"Ya eres miembro de este workspace.","settings.members.flash.invite_accepted":"¡Bienvenido! Ahora eres miembro del workspace.","settings.members.flash.invite_declined":"Invitación rechazada.","settings.account.tabs.account":"Cuenta","settings.account.tabs.usage":"Uso","settings.account.tabs.billing":"Facturación","settings.account.title":"Configuración de cuenta","settings.account.description":"Gestiona el nombre de la cuenta y el correo de facturación","settings.account.name":"Nombre de la cuenta","settings.account.name_placeholder":"Mi Empresa","settings.account.billing_email":"Correo de facturación","settings.account.billing_email_placeholder":"facturacion@empresa.com","settings.account.billing_email_hint":"Este correo se usará para facturas y comunicaciones de facturación de Stripe.","settings.account.submit":"Guardar","settings.flash.account_updated":"¡Cuenta actualizada correctamente!","settings.flash.profile_updated":"¡Perfil actualizado correctamente!","settings.flash.language_updated":"¡Idioma actualizado correctamente!","settings.flash.password_updated":"¡Contraseña actualizada correctamente!","settings.flash.workspace_updated":"¡Configuración actualizada correctamente!","settings.flash.photo_updated":"¡Foto actualizada correctamente!","settings.flash.photo_deleted":"¡Foto eliminada correctamente!","settings.flash.logo_updated":"¡Logo subido correctamente!","settings.flash.logo_deleted":"¡Logo eliminado correctamente!","settings.flash.notifications_updated":"¡Preferencias de notificaciones actualizadas!","settings.api_keys.title":"Claves API","settings.api_keys.page_title":"Claves API","settings.api_keys.heading":"Claves API","settings.api_keys.description":"Administra claves API para acceso programático a tu workspace.","settings.api_keys.create":"Crear clave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Tu nueva clave API ha sido creada. Cópiala ahora — no podrás verla de nuevo.","settings.api_keys.table.name":"Nombre","settings.api_keys.table.key":"Clave","settings.api_keys.table.status":"Estado","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID de clave API","settings.api_keys.actions.copy_id_success":"ID de clave API copiado","settings.api_keys.actions.delete":"Eliminar","settings.api_keys.empty.title":"No hay claves API","settings.api_keys.empty.description":"Crea una clave API para acceder a tu workspace programáticamente.","settings.api_keys.delete_modal.title":"Eliminar clave API","settings.api_keys.delete_modal.description":"¿Estás seguro de que deseas eliminar esta clave API? Las aplicaciones que la usen perderán acceso inmediatamente.","settings.api_keys.delete_modal.action":"Eliminar clave API","settings.api_keys.create_dialog.title":"Crear clave API","settings.api_keys.create_dialog.description":"Crea una nueva clave API para acceso programático a tu workspace.","settings.api_keys.create_dialog.name":"Nombre","settings.api_keys.create_dialog.name_placeholder":"ej. Clave API de Producción","settings.api_keys.create_dialog.expires":"Fecha de expiración (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sin expiración","settings.api_keys.create_dialog.submit":"Crear","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"¡Clave API creada correctamente!","settings.api_keys.flash.deleted":"¡Clave API eliminada correctamente!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Seleccionar workspace","sidebar.create_workspace":"Crear workspace","sidebar.create_post":"Crear post","sidebar.profile":"Perfil","sidebar.log_out":"Cerrar sesión","sidebar.workspace.connections":"Conexiones","sidebar.workspace.signatures":"Firmas","sidebar.workspace.labels":"Etiquetas","sidebar.workspace.assets":"Medios","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Seleccionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Oscuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Seleccionar","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Soporte","sidebar.analytics":"Analytics","sidebar.settings":"Configuración","sidebar.posts.calendar":"Calendario","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Programados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Borradores","sidebar.notifications":"Notificaciones","sidebar.mark_all_read":"Marcar todo como leído","sidebar.mark_as_read":"Marcar como leído","sidebar.archive_all":"Archivar todo","sidebar.no_notifications":"Sin notificaciones","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Dar feedback","sidebar.support.last_updates":"Últimas actualizaciones","sidebar.support.docs":"Documentación","signatures.title":"Firmas","signatures.description":"Crea firmas reutilizables para añadir rápidamente a tus posts","signatures.search":"Buscar firmas...","signatures.new":"Nueva firma","signatures.empty_title":"Aún no hay firmas","signatures.empty_description":"Crea firmas para añadir hashtags, links o cualquier texto reutilizable a tus posts","signatures.no_search_results":"Ninguna firma coincide con tu búsqueda","signatures.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","signatures.table.name":"Nombre","signatures.table.content":"Contenido","signatures.table.created_at":"Creado","signatures.actions.edit":"Editar firma","signatures.actions.delete":"Eliminar firma","signatures.create.title":"Crear firma","signatures.create.description":"Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).","signatures.create.name":"Nombre","signatures.create.name_placeholder":"ej: Marketing, Viaje, Cierre de marca","signatures.create.content":"Contenido","signatures.create.content_placeholder":"#marketing #socialmedia\nMás info: https://tumarca.com","signatures.create.content_hint":"Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.","signatures.create.submit":"Crear firma","signatures.create.submitting":"Creando...","signatures.edit.title":"Editar firma","signatures.edit.description":"Actualiza el nombre y el contenido de esta firma.","signatures.edit.name":"Nombre","signatures.edit.name_placeholder":"ej: Marketing, Viaje, Cierre de marca","signatures.edit.content":"Contenido","signatures.edit.content_placeholder":"#marketing #socialmedia\nMás info: https://tumarca.com","signatures.edit.content_hint":"Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.","signatures.edit.submit":"Guardar cambios","signatures.edit.submitting":"Guardando...","signatures.delete.title":"Eliminar firma","signatures.delete.description":"¿Seguro que quieres eliminar esta firma? Esta acción no se puede deshacer.","signatures.delete.confirm":"Eliminar","signatures.delete.cancel":"Cancelar","signatures.flash.created":"Firma creada.","signatures.flash.updated":"Firma actualizada.","signatures.flash.deleted":"Firma eliminada.","usage.title":"Uso","usage.section_account":"Cuenta","usage.section_account_description":"Cuotas y límites de tu plan :plan.","usage.section_ai":"Créditos AI","usage.section_ai_description":"Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.","usage.workspaces":"Workspaces","usage.social_accounts":"Cuentas Sociales","usage.members":"Miembros","usage.credits":"Créditos","validation.accepted":"El campo :attribute debe ser aceptado.","validation.accepted_if":"El campo :attribute debe ser aceptado cuando :other es :value.","validation.active_url":"El campo :attribute debe ser una URL válida.","validation.after":"El campo :attribute debe ser una fecha posterior a :date.","validation.after_or_equal":"El campo :attribute debe ser una fecha posterior o igual a :date.","validation.alpha":"El campo :attribute solo puede contener letras.","validation.alpha_dash":"El campo :attribute solo puede contener letras, números, guiones y guiones bajos.","validation.alpha_num":"El campo :attribute solo puede contener letras y números.","validation.any_of":"El campo :attribute no es válido.","validation.array":"El campo :attribute debe ser un arreglo.","validation.ascii":"El campo :attribute solo puede contener caracteres alfanuméricos de un byte y símbolos.","validation.before":"El campo :attribute debe ser una fecha anterior a :date.","validation.before_or_equal":"El campo :attribute debe ser una fecha anterior o igual a :date.","validation.between.array":"El campo :attribute debe tener entre :min y :max elementos.","validation.between.file":"El campo :attribute debe pesar entre :min y :max kilobytes.","validation.between.numeric":"El campo :attribute debe estar entre :min y :max.","validation.between.string":"El campo :attribute debe tener entre :min y :max caracteres.","validation.boolean":"El campo :attribute debe ser verdadero o falso.","validation.can":"El campo :attribute contiene un valor no autorizado.","validation.confirmed":"La confirmación del campo :attribute no coincide.","validation.contains":"Al campo :attribute le falta un valor requerido.","validation.current_password":"La contraseña es incorrecta.","validation.date":"El campo :attribute debe ser una fecha válida.","validation.date_equals":"El campo :attribute debe ser una fecha igual a :date.","validation.date_format":"El campo :attribute debe coincidir con el formato :format.","validation.decimal":"El campo :attribute debe tener :decimal decimales.","validation.declined":"El campo :attribute debe ser rechazado.","validation.declined_if":"El campo :attribute debe ser rechazado cuando :other es :value.","validation.different":"El campo :attribute y :other deben ser diferentes.","validation.digits":"El campo :attribute debe tener :digits dígitos.","validation.digits_between":"El campo :attribute debe tener entre :min y :max dígitos.","validation.dimensions":"El campo :attribute tiene dimensiones de imagen no válidas.","validation.distinct":"El campo :attribute tiene un valor duplicado.","validation.doesnt_contain":"El campo :attribute no debe contener ninguno de los siguientes: :values.","validation.doesnt_end_with":"El campo :attribute no debe terminar con uno de los siguientes: :values.","validation.doesnt_start_with":"El campo :attribute no debe comenzar con uno de los siguientes: :values.","validation.email":"El campo :attribute debe ser un correo electrónico válido.","validation.encoding":"El campo :attribute debe estar codificado en :encoding.","validation.ends_with":"El campo :attribute debe terminar con uno de los siguientes: :values.","validation.enum":"El :attribute seleccionado no es válido.","validation.exists":"El :attribute seleccionado no es válido.","validation.extensions":"El campo :attribute debe tener una de las siguientes extensiones: :values.","validation.file":"El campo :attribute debe ser un archivo.","validation.filled":"El campo :attribute debe tener un valor.","validation.gt.array":"El campo :attribute debe tener más de :value elementos.","validation.gt.file":"El campo :attribute debe pesar más de :value kilobytes.","validation.gt.numeric":"El campo :attribute debe ser mayor que :value.","validation.gt.string":"El campo :attribute debe tener más de :value caracteres.","validation.gte.array":"El campo :attribute debe tener :value elementos o más.","validation.gte.file":"El campo :attribute debe pesar :value kilobytes o más.","validation.gte.numeric":"El campo :attribute debe ser mayor o igual a :value.","validation.gte.string":"El campo :attribute debe tener :value caracteres o más.","validation.hex_color":"El campo :attribute debe ser un color hexadecimal válido.","validation.image":"El campo :attribute debe ser una imagen.","validation.in":"El :attribute seleccionado no es válido.","validation.in_array":"El campo :attribute debe existir en :other.","validation.in_array_keys":"El campo :attribute debe contener al menos una de las siguientes claves: :values.","validation.integer":"El campo :attribute debe ser un número entero.","validation.ip":"El campo :attribute debe ser una dirección IP válida.","validation.ipv4":"El campo :attribute debe ser una dirección IPv4 válida.","validation.ipv6":"El campo :attribute debe ser una dirección IPv6 válida.","validation.json":"El campo :attribute debe ser una cadena JSON válida.","validation.list":"El campo :attribute debe ser una lista.","validation.lowercase":"El campo :attribute debe estar en minúsculas.","validation.lt.array":"El campo :attribute debe tener menos de :value elementos.","validation.lt.file":"El campo :attribute debe pesar menos de :value kilobytes.","validation.lt.numeric":"El campo :attribute debe ser menor que :value.","validation.lt.string":"El campo :attribute debe tener menos de :value caracteres.","validation.lte.array":"El campo :attribute no debe tener más de :value elementos.","validation.lte.file":"El campo :attribute debe pesar :value kilobytes o menos.","validation.lte.numeric":"El campo :attribute debe ser menor o igual a :value.","validation.lte.string":"El campo :attribute debe tener :value caracteres o menos.","validation.mac_address":"El campo :attribute debe ser una dirección MAC válida.","validation.max.array":"El campo :attribute no debe tener más de :max elementos.","validation.max.file":"El campo :attribute no debe pesar más de :max kilobytes.","validation.max.numeric":"El campo :attribute no debe ser mayor que :max.","validation.max.string":"El campo :attribute no debe tener más de :max caracteres.","validation.max_digits":"El campo :attribute no debe tener más de :max dígitos.","validation.mimes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.mimetypes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.min.array":"El campo :attribute debe tener al menos :min elementos.","validation.min.file":"El campo :attribute debe pesar al menos :min kilobytes.","validation.min.numeric":"El campo :attribute debe ser al menos :min.","validation.min.string":"El campo :attribute debe tener al menos :min caracteres.","validation.min_digits":"El campo :attribute debe tener al menos :min dígitos.","validation.missing":"El campo :attribute debe estar ausente.","validation.missing_if":"El campo :attribute debe estar ausente cuando :other es :value.","validation.missing_unless":"El campo :attribute debe estar ausente a menos que :other sea :value.","validation.missing_with":"El campo :attribute debe estar ausente cuando :values está presente.","validation.missing_with_all":"El campo :attribute debe estar ausente cuando :values están presentes.","validation.multiple_of":"El campo :attribute debe ser múltiplo de :value.","validation.not_in":"El :attribute seleccionado no es válido.","validation.not_regex":"El formato del campo :attribute no es válido.","validation.numeric":"El campo :attribute debe ser un número.","validation.password.letters":"El campo :attribute debe contener al menos una letra.","validation.password.mixed":"El campo :attribute debe contener al menos una letra mayúscula y una minúscula.","validation.password.numbers":"El campo :attribute debe contener al menos un número.","validation.password.symbols":"El campo :attribute debe contener al menos un símbolo.","validation.password.uncompromised":"El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.","validation.present":"El campo :attribute debe estar presente.","validation.present_if":"El campo :attribute debe estar presente cuando :other es :value.","validation.present_unless":"El campo :attribute debe estar presente a menos que :other sea :value.","validation.present_with":"El campo :attribute debe estar presente cuando :values está presente.","validation.present_with_all":"El campo :attribute debe estar presente cuando :values están presentes.","validation.prohibited":"El campo :attribute está prohibido.","validation.prohibited_if":"El campo :attribute está prohibido cuando :other es :value.","validation.prohibited_if_accepted":"El campo :attribute está prohibido cuando :other es aceptado.","validation.prohibited_if_declined":"El campo :attribute está prohibido cuando :other es rechazado.","validation.prohibited_unless":"El campo :attribute está prohibido a menos que :other esté en :values.","validation.prohibits":"El campo :attribute prohíbe que :other esté presente.","validation.regex":"El formato del campo :attribute no es válido.","validation.required":"El campo :attribute es obligatorio.","validation.required_array_keys":"El campo :attribute debe contener entradas para: :values.","validation.required_if":"El campo :attribute es obligatorio cuando :other es :value.","validation.required_if_accepted":"El campo :attribute es obligatorio cuando :other es aceptado.","validation.required_if_declined":"El campo :attribute es obligatorio cuando :other es rechazado.","validation.required_unless":"El campo :attribute es obligatorio a menos que :other esté en :values.","validation.required_with":"El campo :attribute es obligatorio cuando :values está presente.","validation.required_with_all":"El campo :attribute es obligatorio cuando :values están presentes.","validation.required_without":"El campo :attribute es obligatorio cuando :values no está presente.","validation.required_without_all":"El campo :attribute es obligatorio cuando ninguno de :values está presente.","validation.same":"El campo :attribute debe coincidir con :other.","validation.size.array":"El campo :attribute debe contener :size elementos.","validation.size.file":"El campo :attribute debe pesar :size kilobytes.","validation.size.numeric":"El campo :attribute debe ser :size.","validation.size.string":"El campo :attribute debe tener :size caracteres.","validation.starts_with":"El campo :attribute debe comenzar con uno de los siguientes: :values.","validation.string":"El campo :attribute debe ser una cadena de texto.","validation.timezone":"El campo :attribute debe ser una zona horaria válida.","validation.unique":"El :attribute ya ha sido registrado.","validation.uploaded":"El :attribute no se pudo subir.","validation.uppercase":"El campo :attribute debe estar en mayúsculas.","validation.url":"El campo :attribute debe ser una URL válida.","validation.ulid":"El campo :attribute debe ser un ULID válido.","validation.uuid":"El campo :attribute debe ser un UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Tus workspaces","workspaces.select_description":"Selecciona un workspace para continuar","workspaces.current":"Actual","workspaces.connections":":count conexiones","workspaces.posts":":count posts","workspaces.create.page_title":"Crea tu workspace","workspaces.create.title":"Configura tu workspace","workspaces.create.description":"Cuéntanos un poco sobre ti o tu proyecto. Lo usaremos para personalizar las publicaciones generadas por IA con tu voz.","workspaces.create.website":"Sitio web","workspaces.create.website_placeholder":"https://tumarca.com","workspaces.create.autofill":"Autocompletar desde el sitio","workspaces.create.autofill_missing_url":"Ingresa una URL primero.","workspaces.create.autofill_success":"Información de la marca cargada.","workspaces.create.autofill_error":"No se pudo autocompletar. Puedes llenar los campos manualmente.","workspaces.create.autofill_errors.unreachable":"No pudimos acceder a ese sitio web (:reason).","workspaces.create.autofill_errors.http_status":"El sitio web devolvió un estado inesperado (:status).","workspaces.create.autofill_errors.invalid_scheme":"Solo se admiten URLs http y https.","workspaces.create.autofill_errors.missing_host":"A la URL le falta un host.","workspaces.create.autofill_errors.unresolvable_host":"No pudimos resolver el host (:host).","workspaces.create.autofill_errors.private_network":"No se permiten URLs que apunten a redes privadas.","workspaces.create.logo_captured":"Logo capturado de tu sitio.","workspaces.create.name":"Nombre del workspace","workspaces.create.name_placeholder":"ej. Acme Inc","workspaces.create.brand_description":"Descripción de la marca","workspaces.create.brand_description_placeholder":"¿Qué hace tu marca?","workspaces.create.tone":"Tono de la marca","workspaces.create.tone_professional":"Profesional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Amigable","workspaces.create.tone_bold":"Audaz","workspaces.create.tone_inspirational":"Inspirador","workspaces.create.tone_humorous":"Humorístico","workspaces.create.tone_educational":"Educativo","workspaces.create.content_language":"Idioma del contenido","workspaces.create.content_language_description":"Las descripciones generadas por IA se escribirán en este idioma.","workspaces.create.voice_notes":"Notas de voz (opcional)","workspaces.create.voice_notes_placeholder":"ej. frases cortas y directas. evita jerga.","workspaces.create.brand_color":"Color de marca","workspaces.create.background_color":"Color de fondo","workspaces.create.text_color":"Color de texto","workspaces.create.submit":"Crear workspace","workspaces.create.success":"Workspace creado. Conecta una cuenta social para empezar a publicar.","workspaces.limit_reached":"Has alcanzado el límite de workspaces de tu plan.","workspaces.flash.deleted":"Workspace eliminado correctamente."} \ No newline at end of file +{"accounts.title":"Conexiones","accounts.page_title":"Cuentas Sociales","accounts.description":"Resumen de todas tus cuentas sociales conectadas","accounts.add_social":"Agregar Red Social","accounts.add_social_title":"Conectar una Cuenta Social","accounts.add_social_description":"Conecta una cuenta social a TryPost para empezar a publicar","accounts.connect_cta":"Conectar","accounts.no_accounts":"No hay cuentas conectadas todavía","accounts.no_accounts_description":"Conecta tus redes sociales para empezar a programar y publicar posts","accounts.no_search_results":"Ninguna cuenta coincide con tu búsqueda","accounts.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","accounts.search":"Buscar cuentas...","accounts.added":"Agregada :date","accounts.limit_reached":"Has alcanzado el límite de cuentas sociales de tu plan.","accounts.not_connected":"No conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexión perdida","accounts.reconnect_account":"Reconectar cuenta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.table.account":"Cuenta","accounts.table.platform":"Plataforma","accounts.table.status":"Estado","accounts.table.last_used":"Último uso","accounts.table.added":"Añadida","accounts.table.active":"Activa","accounts.never_used":"Nunca usada","accounts.status.connected":"Conectada","accounts.status.disconnected":"Desconectada","accounts.descriptions.linkedin":"Conecta tu perfil personal de LinkedIn","accounts.descriptions.linkedin-page":"Conecta una página de empresa de LinkedIn","accounts.descriptions.x":"Conecta tu cuenta de X (Twitter)","accounts.descriptions.tiktok":"Conecta tu cuenta de TikTok","accounts.descriptions.youtube":"Conecta un canal de YouTube","accounts.descriptions.facebook":"Conecta una página de Facebook","accounts.descriptions.instagram":"Conecta una cuenta profesional de Instagram","accounts.descriptions.instagram-facebook":"Conecta Instagram vía página de Facebook","accounts.descriptions.threads":"Conecta tu cuenta de Threads","accounts.descriptions.pinterest":"Conecta tu cuenta de Pinterest","accounts.descriptions.bluesky":"Conecta tu cuenta de Bluesky","accounts.descriptions.mastodon":"Conecta tu cuenta de Mastodon","accounts.disconnect_modal.title":"Desconectar cuenta","accounts.disconnect_modal.description":"¿Estás seguro de que deseas desconectar esta cuenta? Puedes volver a conectarla en cualquier momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Introduce tus credenciales para conectar","accounts.bluesky.email":"Correo electrónico","accounts.bluesky.email_placeholder":"tuusuario.bsky.social","accounts.bluesky.app_password":"Contraseña de app","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Usa una Contraseña de App por seguridad. Crea una en bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Introduce tu instancia de Mastodon","accounts.mastodon.instance_url":"URL de la instancia","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Introduce la URL de tu instancia de Mastodon (ej: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar con Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Seleccionar página de Facebook","accounts.facebook.description":"Elige qué página deseas conectar","accounts.facebook.no_pages":"No se encontraron páginas","accounts.facebook.no_pages_description":"No eres administrador de ninguna página de Facebook.","accounts.facebook.page_label":"Página de Facebook","accounts.instagram_facebook.title":"Seleccionar cuenta de Instagram","accounts.instagram_facebook.description":"Elige qué cuenta de Instagram deseas conectar","accounts.instagram_facebook.no_pages":"No se encontraron cuentas de Instagram","accounts.instagram_facebook.no_pages_description":"No se encontraron páginas de Facebook con cuentas Instagram Business vinculadas.","accounts.linkedin.title":"Seleccionar página de LinkedIn","accounts.linkedin.description":"Elige qué página deseas conectar","accounts.linkedin.no_pages":"No se encontraron páginas","accounts.linkedin.no_pages_description":"No eres administrador de ninguna página de LinkedIn.","accounts.linkedin.page_label":"Página de LinkedIn","accounts.flash.disconnected":"¡Cuenta desconectada correctamente!","accounts.flash.connected":"¡Cuenta conectada correctamente!","accounts.flash.session_expired":"Sesión expirada. Inténtalo de nuevo.","accounts.flash.workspace_not_found":"Workspace no encontrado.","accounts.flash.activated":"¡Cuenta activada!","accounts.flash.deactivated":"¡Cuenta desactivada!","accounts.flash.already_connected":"Esta plataforma ya está conectada.","accounts.flash.no_youtube_channels":"No se encontraron canales de YouTube. Crea un canal primero.","accounts.popup_callback.title_success":"Conectado","accounts.popup_callback.title_error":"Error","accounts.popup_callback.closing":"Esta ventana se cerrará automáticamente...","accounts.popup_callback.close_now":"Puedes cerrar esta ventana ahora.","accounts.popup_callback.connected":"¡Cuenta conectada!","accounts.popup_callback.reconnected":"¡Cuenta reconectada!","accounts.popup_callback.error_connecting":"Error al conectar la cuenta. Inténtalo de nuevo.","accounts.popup_callback.error_connecting_page":"Error al conectar la página. Inténtalo de nuevo.","accounts.popup_callback.error_connecting_channel":"Error al conectar el canal. Inténtalo de nuevo.","accounts.popup_callback.session_expired":"Sesión expirada. Inténtalo de nuevo.","accounts.popup_callback.workspace_not_found":"Workspace no encontrado.","accounts.popup_callback.invalid_state":"Estado inválido. Inténtalo de nuevo.","accounts.popup_callback.failed_to_authenticate":"Falló la autenticación.","accounts.popup_callback.failed_to_get_profile":"Falló al obtener el perfil.","accounts.popup_callback.page_not_found":"Página no encontrada.","accounts.popup_callback.channel_not_found":"Canal no encontrado.","accounts.popup_callback.no_facebook_pages":"No se encontraron páginas de Facebook. Debes ser administrador de al menos una página.","accounts.popup_callback.no_facebook_instagram_pages":"No se encontraron páginas de Facebook con cuentas de Instagram vinculadas.","accounts.popup_callback.no_youtube_channels":"No se encontraron canales de YouTube. Crea un canal primero.","accounts.popup_callback.not_linkedin_admin":"No eres administrador de ninguna página de LinkedIn.","analytics.no_accounts":"No hay cuentas conectadas con analytics.","analytics.no_accounts_match":"Ninguna cuenta coincide.","analytics.search_account":"Buscar cuenta…","analytics.select_account":"Selecciona una cuenta para ver analytics.","analytics.no_data":"No hay datos de analytics disponibles.","analytics.metrics.avg_view_duration":"Duración Media (s)","analytics.metrics.avg_view_percentage":"Porcentaje Medio de Visualización","analytics.metrics.bookmarks":"Guardados","analytics.metrics.clicks":"Clics","analytics.metrics.comments":"Comentarios","analytics.metrics.engagement":"Engagement","analytics.metrics.favourites":"Favoritos","analytics.metrics.followers":"Seguidores","analytics.metrics.following":"Siguiendo","analytics.metrics.impressions":"Impresiones","analytics.metrics.interactions":"Interacciones","analytics.metrics.likes":"Me gusta","analytics.metrics.minutes_watched":"Minutos Vistos","analytics.metrics.organic_followers":"Seguidores Orgánicos","analytics.metrics.outbound_clicks":"Clics Externos","analytics.metrics.page_followers":"Seguidores de la Página","analytics.metrics.page_reach":"Alcance de la Página","analytics.metrics.page_views":"Vistas de la Página","analytics.metrics.paid_followers":"Seguidores Pagados","analytics.metrics.pin_click_rate":"Tasa de Clics en Pines","analytics.metrics.pin_clicks":"Clics en Pines","analytics.metrics.posts_engagement":"Engagement de Publicaciones","analytics.metrics.posts_reach":"Alcance de Publicaciones","analytics.metrics.quotes":"Citas","analytics.metrics.reach":"Alcance","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Comentarios Recientes","analytics.metrics.recent_likes":"Me Gusta Recientes","analytics.metrics.recent_shares":"Compartidos Recientes","analytics.metrics.replies":"Respuestas","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Guardados","analytics.metrics.shares":"Compartidos","analytics.metrics.subscribers_gained":"Suscriptores Ganados","analytics.metrics.subscribers_lost":"Suscriptores Perdidos","analytics.metrics.total_likes":"Total de Me gusta","analytics.metrics.video_views":"Vistas de Vídeo","analytics.metrics.videos":"Vídeos","analytics.metrics.views":"Vistas","assets.title":"Medios","assets.tabs.my_uploads":"Mis subidas","assets.tabs.stock_photos":"Fotos gratuitas","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Arrastra y suelta tus archivos aquí o haz clic para seleccionar","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Subiendo...","assets.empty.title":"Todavía no hay medios","assets.empty.description":"Sube imágenes y videos para construir tu biblioteca de medios.","assets.save_to_assets":"Guardar en la biblioteca","assets.saved":"¡Guardado en tu biblioteca!","assets.create_post":"Crear post","assets.add_to_post":"Agregar al post","assets.search_placeholder":"Buscar media...","assets.delete.title":"Eliminar medio","assets.delete.description":"¿Estás seguro de que deseas eliminar este medio? Esta acción no se puede deshacer.","assets.delete.confirm":"Eliminar","assets.delete.cancel":"Cancelar","assets.unsplash.search_placeholder":"Buscar fotos gratuitas...","assets.unsplash.no_results":"No se encontraron fotos","assets.unsplash.no_results_description":"Prueba con otro término de búsqueda.","assets.unsplash.trending":"Tendencias en Unsplash","assets.unsplash.start_searching":"Busca fotos gratuitas de Unsplash","assets.giphy.trending":"Tendencias en Giphy","assets.giphy.search_placeholder":"Buscar GIFs...","assets.giphy.no_results":"No se encontraron GIFs","assets.giphy.no_results_description":"Prueba con otro término de búsqueda.","assets.giphy.powered_by":"Powered by GIPHY","auth.failed":"Estas credenciales no coinciden con nuestros registros.","auth.password":"La contraseña proporcionada es incorrecta.","auth.throttle":"Demasiados intentos de inicio de sesión. Inténtalo de nuevo en :seconds segundos.","auth.flash.welcome":"¡Bienvenido a TryPost!","auth.flash.welcome_trial":"¡Bienvenido a TryPost! Tu prueba ha comenzado.","auth.legal":"Al continuar, aceptas nuestros Términos de Servicio y Política de Privacidad.","auth.slides.calendar.title":"Calendario Visual","auth.slides.calendar.description":"Planifica y programa tu contenido con un calendario intuitivo de arrastrar y soltar en todas tus cuentas sociales.","auth.slides.scheduling.title":"Programación Inteligente","auth.slides.scheduling.description":"Programa posts en LinkedIn, X, Instagram, TikTok, YouTube y más — todo desde un solo lugar.","auth.slides.media.title":"Contenido Multimedia","auth.slides.media.description":"Publica imágenes, carruseles, historias y reels. Cada plataforma recibe el formato correcto automáticamente.","auth.slides.video.title":"Publicación de Video","auth.slides.video.description":"Sube videos una vez y publícalos en TikTok, YouTube Shorts, Instagram Reels y Facebook Reels.","auth.slides.team.title":"Workspaces en Equipo","auth.slides.team.description":"Invita a tu equipo, asigna roles y gestiona múltiples marcas en workspaces separados.","auth.slides.signatures.title":"Firmas","auth.slides.signatures.description":"Guarda firmas reutilizables (hashtags, links, despedidas) y añádelas a tus posts con un clic.","auth.or_continue_with":"O continuar con","auth.google_login":"Iniciar sesión con Google","auth.google_signup":"Registrarse con Google","auth.github_login":"Iniciar sesión con GitHub","auth.github_signup":"Registrarse con GitHub","auth.github_email_unavailable":"No fue posible obtener tu correo de GitHub. Haz tu correo público en GitHub o concede el permiso de correo y vuelve a intentar.","auth.signup_success.page_title":"Bienvenido","auth.signup_success.title":"Configurando tu cuenta","auth.signup_success.description":"Esto suele tardar solo unos segundos...","auth.login.title":"Inicia sesión en tu cuenta","auth.login.description":"Introduce tu correo y contraseña para iniciar sesión","auth.login.page_title":"Iniciar sesión","auth.login.email":"Correo electrónico","auth.login.password":"Contraseña","auth.login.forgot_password":"¿Olvidaste tu contraseña?","auth.login.remember_me":"Recuérdame","auth.login.submit":"Iniciar sesión","auth.login.no_account":"¿No tienes una cuenta?","auth.login.sign_up":"Regístrate","auth.register.title":"Crear una cuenta","auth.register.description":"Introduce tus datos para crear tu cuenta","auth.register.page_title":"Registro","auth.register.name":"Nombre","auth.register.name_placeholder":"Nombre completo","auth.register.email":"Correo electrónico","auth.register.password":"Contraseña","auth.register.show_password":"Mostrar contraseña","auth.register.hide_password":"Ocultar contraseña","auth.register.submit":"Crear cuenta","auth.register.has_account":"¿Ya tienes una cuenta?","auth.register.log_in":"Iniciar sesión","auth.forgot_password.title":"Olvidé mi contraseña","auth.forgot_password.description":"Introduce tu correo para recibir un enlace de restablecimiento","auth.forgot_password.page_title":"Olvidé mi contraseña","auth.forgot_password.email":"Correo electrónico","auth.forgot_password.submit":"Enviar enlace de restablecimiento","auth.forgot_password.return_to":"O vuelve a","auth.forgot_password.log_in":"iniciar sesión","auth.reset_password.title":"Restablecer contraseña","auth.reset_password.description":"Introduce tu nueva contraseña","auth.reset_password.page_title":"Restablecer contraseña","auth.reset_password.email":"Correo electrónico","auth.reset_password.password":"Contraseña","auth.reset_password.confirm_password":"Confirmar contraseña","auth.reset_password.confirm_placeholder":"Confirmar contraseña","auth.reset_password.submit":"Restablecer contraseña","auth.verify_email.title":"Verificar correo","auth.verify_email.description":"Verifica tu correo electrónico haciendo clic en el enlace que acabamos de enviarte.","auth.verify_email.page_title":"Verificación de correo","auth.verify_email.link_sent":"Se ha enviado un nuevo enlace de verificación al correo electrónico proporcionado durante el registro.","auth.verify_email.resend":"Reenviar correo de verificación","auth.verify_email.log_out":"Cerrar sesión","auth.accept_invite.page_title":"Aceptar invitación","auth.accept_invite.title":"¡Has sido invitado!","auth.accept_invite.description":"Has sido invitado a unirte al workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Tu rol","auth.accept_invite.email":"Correo electrónico","auth.accept_invite.accept":"Aceptar invitación","auth.accept_invite.decline":"Rechazar invitación","auth.accept_invite.login_prompt":"Inicia sesión o crea una cuenta para aceptar esta invitación.","auth.accept_invite.log_in":"Iniciar sesión","auth.accept_invite.create_account":"Crear cuenta","billing.title":"Facturación","billing.upgrade_dialog.title":"Actualiza tu plan","billing.upgrade_dialog.description":"Elige un plan que se adapte a tus necesidades.","billing.upgrade_dialog.current_plan":"Plan actual","billing.upgrade_dialog.current_short":"Actual","billing.upgrade_dialog.current_badge":"Actual","billing.upgrade_dialog.subscribe":"Suscribirse","billing.upgrade_dialog.switch":"Cambiar a este plan","billing.upgrade_dialog.switch_short":"Cambiar","billing.upgrade_dialog.switch_to_yearly":"Cambiar a anual","billing.upgrade_dialog.switch_to_monthly":"Cambiar a mensual","billing.upgrade_dialog.unavailable":"No disponible","billing.upgrade_dialog.reasons.workspace_limit":"Has alcanzado el límite de workspaces de tu plan. Actualiza para crear más.","billing.upgrade_dialog.reasons.social_account_limit":"Has alcanzado el límite de cuentas sociales de tu plan. Actualiza para conectar más.","billing.upgrade_dialog.reasons.member_limit":"Has alcanzado el límite de miembros de tu plan. Actualiza para invitar a más personas.","billing.subscribe.page_title":"Elige tu plan","billing.subscribe.eyebrow":"Precios","billing.subscribe.title":"Elige el plan ideal para ti","billing.subscribe.description":"Elige el plan que te queda. Facturación mensual o anual.","billing.subscribe.monthly":"Mensual","billing.subscribe.yearly":"Anual","billing.subscribe.per_month":"mensual","billing.subscribe.per_year":"anual","billing.subscribe.billed_monthly":"Facturado mensualmente","billing.subscribe.billed_yearly":"Facturado anualmente","billing.subscribe.features_included":"Qué incluye:","billing.subscribe.everything_in":"Todo lo de :plan, más:","billing.subscribe.save_months":"2 meses gratis","billing.subscribe.popular":"Más popular","billing.subscribe.subscribe_cta":"Suscribirse","billing.subscribe.prices.starter.monthly":"$19","billing.subscribe.prices.starter.yearly_per_month":"$16","billing.subscribe.prices.starter.yearly":"$190","billing.subscribe.prices.plus.monthly":"$29","billing.subscribe.prices.plus.yearly_per_month":"$24","billing.subscribe.prices.plus.yearly":"$290","billing.subscribe.prices.pro.monthly":"$49","billing.subscribe.prices.pro.yearly_per_month":"$41","billing.subscribe.prices.pro.yearly":"$490","billing.subscribe.prices.max.monthly":"$99","billing.subscribe.prices.max.yearly_per_month":"$83","billing.subscribe.prices.max.yearly":"$990","billing.subscribe.features.social_accounts":":count cuentas sociales","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count miembros del equipo","billing.subscribe.features.credits":":count créditos IA/mes","billing.subscribe.credit_tooltips.starter":"En promedio 150 posts de largo medio + 5 imágenes IA por mes.","billing.subscribe.credit_tooltips.plus":"En promedio 300 posts de largo medio + 10 imágenes IA por mes.","billing.subscribe.credit_tooltips.pro":"En promedio 700 posts de largo medio + 30 imágenes IA por mes.","billing.subscribe.credit_tooltips.max":"En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.","billing.plan.title":"Plan","billing.plan.description":"Gestiona tu plan de suscripción.","billing.plan.change":"Cambiar plan","billing.plan.label":"Plan","billing.plan.price":"Precio","billing.plan.month":"mes","billing.plan.trial":"Prueba","billing.plan.active":"Activo","billing.plan.past_due":"Vencido","billing.plan.cancelling":"Cancelando","billing.plan.trial_ends":"La prueba termina en","billing.subscription.title":"Suscripción","billing.subscription.description":"Gestiona tu método de pago, datos de facturación y suscripción.","billing.subscription.payment_method":"Método de pago","billing.subscription.no_payment_method":"Aún no hay método de pago registrado.","billing.subscription.expires_on":"Vence el :month/:year","billing.subscription.manage_label":"Suscripción","billing.subscription.manage_stripe":"Gestionar en Stripe","billing.invoices.title":"Facturas","billing.invoices.description":"Descarga tus facturas anteriores.","billing.invoices.empty":"No se encontraron facturas","billing.invoices.paid":"Pagado","billing.flash.plan_changed":"Ahora estás en el plan :plan.","billing.flash.cannot_manage":"Solo el propietario de la cuenta puede gestionar la facturación.","billing.flash.cannot_downgrade.workspaces":"No puedes cambiar a :plan: tienes :count workspaces pero el plan solo permite :limit.","billing.flash.cannot_downgrade.social_accounts":"No puedes cambiar a :plan: tienes :count cuentas sociales pero el plan solo permite :limit.","billing.flash.cannot_downgrade.members":"No puedes cambiar a :plan: tienes :count miembros (incluyendo invitaciones) pero el plan solo permite :limit.","billing.flash.credits_exhausted":"Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.","billing.processing.page_title":"Procesando...","billing.processing.title":"Procesando tu suscripción","billing.processing.description":"Espera mientras configuramos tu cuenta. Solo tomará un momento.","billing.processing.success_title":"¡Todo listo!","billing.processing.success_description":"Tu suscripción está activa. Redirigiendo a tus workspaces...","billing.processing.cancelled_title":"Pago cancelado","billing.processing.cancelled_description":"Tu pago fue cancelado. No se realizaron cargos.","billing.processing.retry":"Intentar de nuevo","brands.new_brand":"Nueva Marca","brands.no_brands_yet":"No hay marcas todavía","brands.no_brands_description":"Crea marcas para organizar tus cuentas de redes sociales por cliente o proyecto","brands.accounts_count":":count cuentas","brands.create.title":"Crear Marca","brands.create.description":"Dale un nombre a tu marca para agrupar cuentas de redes sociales","brands.create.name":"Nombre de la Marca","brands.create.name_placeholder":"ej. Acme Corp, Personal","brands.create.submit":"Crear Marca","brands.create.submitting":"Creando...","brands.edit.title":"Editar Marca","brands.edit.description":"Actualiza el nombre de esta marca","brands.edit.name":"Nombre de la Marca","brands.edit.name_placeholder":"ej. Acme Corp, Personal","brands.edit.submit":"Guardar Cambios","brands.edit.submitting":"Guardando...","brands.delete.title":"Eliminar Marca","brands.delete.description":"¿Estás seguro de que deseas eliminar esta marca? Las cuentas de redes sociales se desasignarán pero no se eliminarán.","brands.delete.confirm":"Eliminar","brands.delete.cancel":"Cancelar","brands.flash.created":"¡Marca creada con éxito!","brands.flash.updated":"¡Marca actualizada con éxito!","brands.flash.deleted":"¡Marca eliminada con éxito!","calendar.title":"Calendario","calendar.today":"Hoy","calendar.day":"Día","calendar.week":"Semana","calendar.month":"Mes","calendar.new_post":"Nuevo post","calendar.no_content":"Sin contenido","calendar.more":"+:count más","comments.placeholder":"Escribe un comentario...","comments.reply_placeholder":"Escribe una respuesta...","comments.reply":"Responder","comments.edit":"Editar","comments.delete":"Eliminar","comments.edited":"editado","comments.save":"Guardar","comments.cancel":"Cancelar","comments.send":"Enviar","comments.replying_to":"Respondiendo a :name","comments.empty":"Todavía no hay comentarios. Inicia la conversación.","comments.load_more":"Cargar comentarios anteriores","comments.today":"Hoy","comments.yesterday":"Ayer","common.confirm_modal.cannot_be_undone":"Esta acción no se puede deshacer.","common.confirm_modal.type":"Escribe","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar al portapapeles","common.confirm_modal.delete_keyword":"eliminar","common.photo_upload.upload":"Subir","common.photo_upload.uploading":"Subiendo...","common.photo_upload.remove":"Eliminar foto","common.photo_upload.hint":"Recomendado: imagen cuadrada, máximo 2 MB.","common.timezone.select":"Seleccionar zona horaria","common.timezone.search":"Buscar zona horaria...","common.timezone.empty":"No se encontró zona horaria","common.date_picker.select":"Seleccionar fecha","common.date_range_picker.placeholder":"Elige un período","common.date_range_picker.today":"Hoy","common.date_range_picker.yesterday":"Ayer","common.date_range_picker.last_7_days":"Últimos 7 días","common.date_range_picker.last_30_days":"Últimos 30 días","common.date_range_picker.last_3_months":"Últimos 3 meses","common.date_range_picker.last_6_months":"Últimos 6 meses","common.date_range_picker.last_12_months":"Últimos 12 meses","common.date_range_picker.this_month":"Este mes","common.date_range_picker.last_month":"Mes pasado","common.date_range_picker.year_to_date":"Desde inicio del año","common.date_range_picker.last_year":"Año pasado","common.cancel":"Cancelar","common.clear":"Limpiar","common.close":"Cerrar","common.loading_more":"Cargando más...","labels.title":"Etiquetas","labels.description":"Crea etiquetas para organizar y categorizar tus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nueva etiqueta","labels.no_labels_yet":"Aún no hay etiquetas","labels.no_search_results":"Ninguna etiqueta coincide con tu búsqueda","labels.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","labels.create_first_label":"Crea tu primera etiqueta","labels.table.name":"Nombre","labels.table.created_at":"Creado","labels.actions.edit":"Editar etiqueta","labels.actions.delete":"Eliminar etiqueta","labels.create.title":"Crear etiqueta","labels.create.description":"Dale un nombre y elige un color para tu etiqueta","labels.create.name":"Nombre","labels.create.name_placeholder":"Nombre de la etiqueta...","labels.create.color":"Color","labels.create.submit":"Crear etiqueta","labels.create.submitting":"Creando...","labels.edit.title":"Editar etiqueta","labels.edit.description":"Actualiza el nombre y el color de esta etiqueta","labels.edit.name":"Nombre","labels.edit.name_placeholder":"Nombre de la etiqueta...","labels.edit.color":"Color","labels.edit.submit":"Guardar cambios","labels.edit.submitting":"Guardando...","labels.delete.title":"Eliminar etiqueta","labels.delete.description":"¿Estás seguro de que deseas eliminar esta etiqueta? Esta acción no se puede deshacer.","labels.delete.confirm":"Eliminar","labels.delete.cancel":"Cancelar","labels.flash.created":"¡Etiqueta creada correctamente!","labels.flash.updated":"¡Etiqueta actualizada correctamente!","labels.flash.deleted":"¡Etiqueta eliminada correctamente!","mail.mentioned.subject":":name te mencionó en TryPost","mail.mentioned.title":":name te mencionó","mail.mentioned.intro":":name te mencionó en un comentario.","mail.mentioned.cta":"Ver comentario","mail.workspace_connections_disconnected.subject":"{1} :count cuenta necesita ser reconectada en :workspace|[2,*] :count cuentas necesitan ser reconectadas en :workspace","mail.workspace_connections_disconnected.title":"Cuentas necesitan reconexión","mail.workspace_connections_disconnected.intro":"Las siguientes cuentas sociales en tu workspace :workspace se han desconectado y necesitan ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Esto puede haber ocurrido porque:","mail.workspace_connections_disconnected.reason_expired":"Los tokens de acceso expiraron","mail.workspace_connections_disconnected.reason_revoked":"Revocaste el acceso a TryPost en la plataforma","mail.workspace_connections_disconnected.reason_changed":"La plataforma cambió sus requisitos de autenticación","mail.workspace_connections_disconnected.reconnect_cta":"Reconecta estas cuentas para seguir programando y publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar cuentas","notifications.post_ready.title":"Tu publicación está lista","notifications.post_ready.body":"La IA terminó. Toca para revisar y publicar.","notifications.account_disconnected.title":"Cuenta de :platform desconectada","notifications.account_disconnected.body":":account necesita reconectarse","notifications.account_token_expired.title":"Cuenta de :platform necesita reconectarse","notifications.account_token_expired.body":"La sesión de :account expiró — reconéctala para seguir publicando","pagination.previous":"« Anterior","pagination.next":"Siguiente »","passwords.reset":"Tu contraseña ha sido restablecida.","passwords.sent":"Te hemos enviado un enlace para restablecer tu contraseña.","passwords.throttled":"Espera antes de intentarlo de nuevo.","passwords.token":"Este token de restablecimiento de contraseña no es válido.","passwords.user":"No encontramos un usuario con ese correo electrónico.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos los posts","posts.new_post":"Nuevo post","posts.no_posts":"No se encontraron posts","posts.no_search_results":"Ningún post coincide con tu búsqueda","posts.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","posts.start_creating":"Empieza creando tu primer post.","posts.filter_by_label":"Filtrar por etiqueta","posts.label_search_placeholder":"Buscar etiquetas...","posts.no_labels":"No se encontraron etiquetas.","posts.clear_label_filter":"Limpiar filtro de etiquetas","posts.table.post":"Post","posts.table.status":"Estado","posts.table.content":"Contenido","posts.table.platforms":"Plataformas","posts.table.labels":"Etiquetas","posts.table.scheduled_at":"Fecha","posts.table.actions":"","posts.manage_posts":"Administra todos tus posts","posts.delete_confirm":"¿Estás seguro de que deseas eliminar este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Eliminar","posts.actions.duplicate":"Duplicar","posts.actions.copy_id":"Copiar ID","posts.actions.copied":"ID copiado al portapapeles","posts.form.post_type":"Tipo de post","posts.form.board":"Tablero","posts.form.select_board":"Seleccionar tablero","posts.form.search_board":"Buscar tablero...","posts.form.no_board_found":"No se encontró tablero","posts.form.media":"Multimedia","posts.form.min":"Min","posts.form.uploading":"Subiendo...","posts.form.drop_to_upload":"Suelta para subir","posts.form.drag_and_drop":"Arrastra y suelta o haz clic para subir","posts.form.photos_and_videos":"Fotos y videos","posts.form.photos_only":"Solo fotos","posts.form.videos_only":"Solo videos","posts.form.drag_to_reorder":"Arrastra para reordenar","posts.form.caption":"Descripción","posts.form.write_caption":"Escribe tu descripción...","posts.form.content_exceeds_platform":":platform: demasiado largo por :over caracteres (máx :limit).","posts.form.tiktok.settings":"Configuración de TikTok","posts.form.tiktok.variant_label":"Tipo de publicación","posts.form.tiktok.variant.video":"Video","posts.form.tiktok.variant.photo":"Carrusel de fotos","posts.form.tiktok.posting_to":"Publicando en","posts.form.tiktok.privacy_level":"¿Quién puede ver este video?","posts.form.tiktok.privacy_placeholder":"Selecciona la visibilidad","posts.form.tiktok.privacy.public":"Público para todos","posts.form.tiktok.privacy.friends":"Amigos mutuos","posts.form.tiktok.privacy.followers":"Seguidores","posts.form.tiktok.privacy.private":"Solo yo","posts.form.tiktok.privacy.private_disabled_branded":"El contenido de marca no puede ser privado.","posts.form.tiktok.privacy_hint":"Las opciones disponibles dependen de la configuración de tu cuenta de TikTok.","posts.form.tiktok.auto_add_music":"Agregar música automáticamente","posts.form.tiktok.auto_add_music_hint":"Disponible solo para fotos. Agrega una música predeterminada que puedes cambiar después.","posts.form.tiktok.yes":"Sí","posts.form.tiktok.no":"No","posts.form.tiktok.allow_users":"Permitir a los usuarios:","posts.form.tiktok.comments":"Comentar","posts.form.tiktok.duet":"Dueto","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Video hecho con IA","posts.form.tiktok.disclose":"Divulgar contenido del video","posts.form.tiktok.disclose_hint":"Activa para divulgar que este video promueve bienes o servicios a cambio de algo de valor. Tu video puede promocionarte a ti, a un tercero o ambos.","posts.form.tiktok.promotional_organic_title":"Tu foto/video será etiquetado como \"Contenido Promocional\".","posts.form.tiktok.promotional_paid_title":"Tu foto/video será etiquetado como \"Asociación pagada\".","posts.form.tiktok.promotional_description":"Esto no se puede cambiar una vez publicado el video.","posts.form.tiktok.compliance_incomplete":"Debes indicar si tu contenido promociona a ti mismo, a un tercero o a ambos.","posts.form.tiktok.privacy_required":"La visibilidad de TikTok es obligatoria al publicar.","posts.form.tiktok.branded_cleared_private":"La visibilidad se borró porque el contenido de marca no puede ser privado.","posts.form.tiktok.interaction_disabled_by_creator":"Desactivado por la configuración de tu cuenta TikTok.","posts.form.tiktok.max_duration_exceeded":"El video dura :duration s pero esta cuenta solo permite videos de hasta :max s.","posts.form.tiktok.processing_hint":"Después de publicar, puede tardar unos minutos en procesarse y aparecer en tu perfil de TikTok.","posts.form.tiktok.brand_organic":"Tu marca","posts.form.tiktok.brand_organic_hint":"Estás promocionándote a ti mismo o a tu propia marca. Este video será clasificado como Brand Organic.","posts.form.tiktok.brand_content":"Contenido patrocinado","posts.form.tiktok.brand_content_hint":"Estás promocionando otra marca o a un tercero. Este video será clasificado como Branded Content.","posts.form.tiktok.compliance.agree":"Al publicar, aceptas la","posts.form.tiktok.compliance.music_usage":"Confirmación de Uso de Música","posts.form.tiktok.compliance.and":"y la","posts.form.tiktok.compliance.branded_policy":"Política de Contenido Patrocinado","posts.form.instagram.settings":"Configuración de Instagram","posts.form.instagram.posting_to":"Publicando en","posts.form.instagram.variant_label":"Tipo de publicación","posts.form.instagram.variant.feed":"Publicación","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Historia","posts.form.instagram.aspect_label":"Proporción","posts.form.instagram.aspect.square":"Cuadrado (1:1)","posts.form.instagram.aspect.portrait":"Vertical (4:5)","posts.form.instagram.aspect.landscape":"Horizontal (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Configuración de Facebook","posts.form.facebook.posting_to":"Publicando en","posts.form.facebook.variant_label":"Tipo de publicación","posts.form.facebook.variant.post":"Publicación","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Historia","posts.form.linkedin.settings":"Configuración de LinkedIn","posts.form.linkedin.settings_page":"Configuración de la Página de LinkedIn","posts.form.linkedin.posting_to":"Publicando en","posts.form.linkedin.variant_label":"Tipo de publicación","posts.form.linkedin.variant.post":"Publicación","posts.form.linkedin.variant.carousel":"Carrusel","posts.form.pinterest.settings":"Configuración de Pinterest","posts.form.pinterest.posting_to":"Publicando en","posts.form.pinterest.variant_label":"Tipo de pin","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carrusel","posts.form.pinterest.board":"Tablero","posts.form.pinterest.select_board":"Selecciona un tablero","posts.form.pinterest.no_boards":"No se encontraron tableros de Pinterest. Crea uno en tu cuenta de Pinterest primero.","posts.form.pinterest.search_board":"Buscar tableros...","posts.form.pinterest.no_board_found":"Ningún tablero coincide con tu búsqueda.","posts.form.pinterest.board_required":"Selecciona un tablero de Pinterest para publicar este post.","posts.form.warnings.no_variant":"Elige un tipo de publicación para continuar.","posts.form.warnings.requires_media":"Este tipo requiere al menos una imagen o video.","posts.form.warnings.max_files_exceeded":"Este tipo acepta hasta :max archivos (tienes :current).","posts.form.warnings.min_files_required":"Este tipo requiere al menos :min archivos (tienes :current).","posts.form.warnings.no_video_allowed":"Este tipo no acepta videos.","posts.form.warnings.no_image_allowed":"Este tipo acepta solo videos.","posts.form.warnings.gif_not_allowed":"Esta red no acepta GIF. Elimínalo o selecciona otra red.","posts.form.warnings.image_too_large":"La imagen supera el límite de :max (la tuya es :current).","posts.form.warnings.video_too_large":"El video supera el límite de :max (el tuyo es :current).","posts.form.warnings.video_too_long":"El video dura :current, pero este tipo permite hasta :max.","posts.form.warnings.aspect_ratio_too_narrow":"La proporción :current es demasiado alta (mínimo :min).","posts.form.warnings.aspect_ratio_too_wide":"La proporción :current es demasiado ancha (máximo :max).","posts.status.pending":"Pendiente","posts.status.draft":"Borrador","posts.status.scheduled":"Programado","posts.status.publishing":"Publicando","posts.status.retrying":"Reintentando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente publicado","posts.status.failed":"Fallido","posts.descriptions.draft":"Posts esperando ser programados","posts.descriptions.scheduled":"Posts programados para publicar","posts.descriptions.published":"Posts ya publicados","posts.ai.generate.button_tooltip":"Generar con IA","posts.ai.generate.title":"Generar post con IA","posts.ai.generate.description":"Describe sobre qué debe ser el post. La IA usará el contexto de tu marca para escribirlo.","posts.ai.generate.prompt_label":"¿De qué trata este post?","posts.ai.generate.prompt_placeholder":"ej: anunciar nuestra nueva función de generación de imágenes para carruseles","posts.ai.generate.preview_label":"Vista previa","posts.ai.generate.start":"Generar","posts.ai.generate.apply":"Usar este contenido","posts.ai.generate.retry":"Intentar de nuevo","posts.ai.generate.cancel":"Cancelar","posts.ai.review.button_tooltip":"Revisar con IA","posts.ai.review.title":"Revisar post con IA","posts.ai.review.description":"La IA analiza gramática, ortografía y claridad. Aplica las sugerencias con las que estés de acuerdo.","posts.ai.review.loading":"Revisando tu texto...","posts.ai.review.no_issues":"No se encontraron problemas. Todo bien.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Sugerencia","posts.ai.review.apply":"Aplicar","posts.ai.review.apply_all":"Aplicar todas","posts.ai.review.applied":"Aplicada","posts.ai.review.cancel":"Cancelar","posts.show.title":"Detalles del post","posts.show.edit":"Editar","posts.show.back":"Volver","posts.show.no_content":"Sin texto","posts.show.platforms":"Plataformas","posts.show.no_platforms":"Ninguna plataforma seleccionada.","posts.show.view_on_platform":"Ver en la plataforma","posts.show.published_on":"Publicado el :date","posts.show.scheduled_for":"Programado para el :date","posts.show.draft":"Borrador","posts.show.status_pending":"Pendiente","posts.show.metrics":"Métricas","posts.show.metrics_loading":"Cargando métricas…","posts.show.metrics_unavailable":"Métricas aún no disponibles para esta plataforma.","posts.show.metrics_empty":"No se devolvieron métricas.","posts.edit.title":"Editar post","posts.edit.view_title":"Ver post","posts.edit.labels":"Etiquetas","posts.edit.signatures":"Firmas","posts.edit.schedule":"Programar","posts.edit.delete":"Eliminar","posts.edit.schedule_for":"Programar para","posts.edit.unschedule":"Desprogramar","posts.edit.saving":"Guardando...","posts.edit.saved":"Guardado","posts.edit.draft":"Borrador","posts.edit.media":"Multimedia","posts.edit.add_media":"Añadir media","posts.edit.caption":"Descripción","posts.edit.caption_placeholder":"Escribe tu descripción...","posts.edit.compose_title":"Crear un post","posts.edit.compose_subtitle":"Compón tu mensaje y agrega media","posts.edit.preview_empty.title":"Ninguna plataforma seleccionada","posts.edit.preview_empty.description":"Selecciona una plataforma para publicar y ver la vista previa.","posts.edit.drop_zone_title":"Añadir media","posts.edit.drop_zone_subtitle":"Arrastra archivos o haz clic para seleccionar","posts.edit.add":"Añadir","posts.edit.publish_to":"Publicar en","posts.edit.organize":"Organizar","posts.edit.no_labels":"Todavía no hay etiquetas creadas","posts.edit.pick_time":"Elegir hora","posts.edit.post_now":"Publicar ahora","posts.edit.time":"Hora","posts.edit.cancel":"Cancelar","posts.edit.schedule_date":"Fecha de programación","posts.edit.view_on_platform":"Ver en la plataforma","posts.edit.platform_status":"Estado de la plataforma","posts.edit.compliance_incomplete":"Algunas configuraciones de plataforma están incompletas o son incompatibles con los medios adjuntos.","posts.edit.compliance.requires_media":"Agrega una imagen o video para publicar aquí.","posts.edit.compliance.too_many_files":"Solo se permiten :max archivo(s) en este formato.","posts.edit.compliance.too_few_files":"Agrega al menos :min archivos para este formato.","posts.edit.compliance.no_videos":"Solo se permiten imágenes en este formato.","posts.edit.compliance.no_images":"Solo se permiten videos en este formato.","posts.edit.compliance.no_gifs":"Los GIFs no son compatibles aquí.","posts.edit.compliance.video_too_large":"El video supera el límite de tamaño de esta plataforma.","posts.edit.compliance.video_too_long":"El video debe durar menos de :seconds segundos en este formato.","posts.edit.compliance.image_too_large":"La imagen supera el límite de tamaño de esta plataforma.","posts.edit.compliance.aspect_ratio_invalid":"La proporción de aspecto no es compatible con este formato.","posts.edit.compliance.no_content_type":"Elige un tipo de contenido para esta plataforma.","posts.edit.publishing":"Publicando...","posts.edit.publishing_overlay_title":"Tu publicación se está enviando","posts.edit.publishing_overlay_subtitle":"Esto puede tardar unos momentos. Puedes salir de esta página sin problemas.","posts.edit.scheduled_overlay_title":"Esta publicación está programada","posts.edit.scheduled_overlay_subtitle":"Programada para :date. Cancela la programación para hacer cambios.","posts.edit.unschedule_cta":"Cancelar para editar","posts.edit.tabs.preview":"Vista previa","posts.edit.tabs.schedule":"Programación","posts.edit.tabs.comments":"Comentarios","posts.edit.tabs.comments_empty":"Todavía no hay comentarios.","posts.edit.media_picker.title":"Elegir de la galería","posts.edit.media_picker.search":"Buscar media...","posts.edit.media_picker.empty":"Aún no hay archivos en tu galería","posts.edit.media_picker.cancel":"Cancelar","posts.edit.media_picker.add":"Agregar","posts.edit.media_picker.add_count":"Agregar :count","posts.edit.emoji_picker.search":"Buscar emoji","posts.edit.emoji_picker.empty":"No se encontraron emojis","posts.edit.emoji_picker.recent":"Usados con frecuencia","posts.edit.emoji_picker.smileys":"Caritas y emociones","posts.edit.emoji_picker.people":"Personas y cuerpo","posts.edit.emoji_picker.nature":"Animales y naturaleza","posts.edit.emoji_picker.food":"Comida y bebida","posts.edit.emoji_picker.activities":"Actividades","posts.edit.emoji_picker.travel":"Viajes y lugares","posts.edit.emoji_picker.objects":"Objetos","posts.edit.emoji_picker.symbols":"Símbolos","posts.edit.emoji_picker.flags":"Banderas","posts.edit.status.scheduled":"Programado","posts.edit.status.published":"Publicado","posts.edit.status.publishing":"Publicando...","posts.edit.status.retrying":"Reintentando...","posts.edit.status.failed":"Fallido","posts.edit.delete_modal.title":"Eliminar post","posts.edit.delete_modal.description":"¿Estás seguro de que deseas eliminar este post? Esta acción no se puede deshacer.","posts.edit.delete_modal.action":"Eliminar","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"¿Activar sincronización?","posts.edit.sync_enable.description":"Todas las plataformas compartirán el mismo contenido. Las ediciones personalizadas realizadas en plataformas individuales serán reemplazadas con el contenido actual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Activar sincronización","posts.edit.sync_disable.title":"¿Desactivar sincronización?","posts.edit.sync_disable.description":"Cada plataforma mantendrá su contenido actual, pero las ediciones futuras solo se aplicarán a la plataforma que estés editando.","posts.edit.sync_disable.customize_note":"Podrás personalizar el contenido de cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desactivar sincronización","posts.edit.platforms_dialog.title":"Seleccionar plataformas","posts.edit.platforms_dialog.description":"Elige en qué plataformas publicar este post.","posts.edit.signatures_modal.search":"Buscar firmas...","posts.edit.signatures_modal.no_results":"No se encontraron firmas.","posts.edit.validation.select_board":"Selecciona un tablero","posts.edit.validation.images_not_supported":"Imágenes no soportadas","posts.edit.validation.videos_not_supported":"Videos no soportados","posts.edit.validation.max_images":"Máximo :count imágenes","posts.edit.validation.requires_media":"Requiere multimedia","posts.edit.validation.requires_content":"Se requiere texto","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform no soporta imágenes","posts.edit.validation.supports_up_to_images":":platform soporta hasta :count imágenes","posts.edit.validation.does_not_support_videos":":platform no soporta videos","posts.content_types.instagram_feed.label":"Post del feed","posts.content_types.instagram_feed.description":"Aparece en tu feed y perfil","posts.content_types.instagram_reel.label":"Reel","posts.content_types.instagram_reel.description":"Video corto de hasta 90 segundos","posts.content_types.instagram_story.label":"Historia","posts.content_types.instagram_story.description":"Desaparece después de 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_carousel.label":"Carrusel","posts.content_types.linkedin_carousel.description":"Imágenes deslizables","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post estándar con texto y multimedia","posts.content_types.linkedin_page_carousel.label":"Carrusel","posts.content_types.linkedin_page_carousel.description":"Imágenes deslizables","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post estándar en tu página","posts.content_types.facebook_reel.label":"Reel","posts.content_types.facebook_reel.description":"Video corto de hasta 90 segundos","posts.content_types.facebook_story.label":"Historia","posts.content_types.facebook_story.description":"Desaparece después de 24 horas","posts.content_types.tiktok_video.label":"Video","posts.content_types.tiktok_video.description":"Contenido de video corto","posts.content_types.tiktok_photo.label":"Carrusel de fotos","posts.content_types.tiktok_photo.description":"Hasta 35 fotos en un carrusel deslizable","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Video vertical de hasta 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet con texto y multimedia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto con multimedia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagen estándar","posts.content_types.pinterest_video_pin.label":"Pin de video","posts.content_types.pinterest_video_pin.description":"Pin de video (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carrusel","posts.content_types.pinterest_carousel.description":"Carrusel multi-imagen (2-5 imágenes)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto con imágenes opcionales","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto con multimedia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página de LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página de Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"¡Post programado correctamente!","posts.flash.deleted":"¡Post eliminado correctamente!","posts.flash.duplicated":"Post duplicado como borrador.","posts.flash.cannot_edit_published":"Los posts publicados no se pueden editar.","posts.flash.cannot_delete_published":"Los posts publicados no se pueden eliminar.","posts.flash.connect_first":"Conecta al menos una red social antes de crear un post.","posts.errors.account_disconnected":"Cuenta social desconectada","posts.errors.account_inactive":"Cuenta social desactivada","posts.errors.account_token_expired":"Sesión de la cuenta social expirada — reconecta la cuenta","posts.delete.title":"¿Eliminar post?","posts.delete.description":"Esta acción no se puede deshacer. El post y todos sus archivos multimedia se eliminarán de forma permanente.","posts.delete.confirm":"Sí, eliminar","posts.delete.cancel":"Cancelar","posts.create.title":"Crear nuevo post","posts.create.description":"Elige cómo quieres empezar.","posts.create.scratch_title":"Empezar desde cero","posts.create.scratch_description":"Abre un post en blanco para escribirlo todo.","posts.create.ai_title":"Generar con IA","posts.create.ai_description":"Describe lo que quieres y la IA genera el contenido por ti.","posts.create.ai_configure_description":"Elige un formato y describe el post que quieres crear.","posts.create.template_title":"Usar una plantilla","posts.create.template_description":"Elige una de nuestras plantillas y personalízala.","posts.create.preview.image_title":"Título de la imagen","posts.create.preview.image_body":"Texto de la imagen","posts.create.coming_soon":"Próximamente","posts.create.steps.format_title":"Elige un formato","posts.create.steps.format_description":"Selecciona el tipo de post que quieres crear.","posts.create.steps.account_title":"Elige una cuenta","posts.create.steps.account_description":"Selecciona la cuenta social donde publicar.","posts.create.steps.media_title":"Opciones de medios","posts.create.steps.media_carousel":"¿Cuántas diapositivas?","posts.create.steps.media_optional":"¿Incluir imágenes?","posts.create.steps.media_optional_label":"¿Cuántas imágenes?","posts.create.steps.media_none":"Ninguna","posts.create.steps.media_count_label":"Número de imágenes","posts.create.steps.prompt_title":"Describe tu post","posts.create.steps.prompt_label":"¿De qué trata este post?","posts.create.steps.prompt_placeholder":"Ej. Anuncia nuestra nueva función de carrusel para Instagram","posts.create.steps.preview_error":"Algo salió mal. Por favor, inténtalo de nuevo.","posts.create.steps.loading_page_title":"Generando tu publicación","posts.create.steps.loading_eta":"Tiempo estimado: cerca de :minutes.","posts.create.steps.loading_eta_minute_one":"1 minuto","posts.create.steps.loading_eta_minute_other":":count minutos","posts.create.steps.loading_leave_title":"Puedes seguir trabajando.","posts.create.steps.loading_leave_body":"Te avisamos cuando la publicación esté lista.","posts.create.steps.loading_leave_cta":"Ir al calendario","posts.create.steps.loading_create_another_cta":"Crear otra publicación","posts.create.steps.loading_tip_credits":"Cada imagen IA usa unos 15 créditos.","posts.create.steps.loading_tip_edit":"Podrás editar todo cuando la publicación esté lista.","posts.create.steps.loading_tip_draft":"Las publicaciones generadas van directo a tus borradores.","posts.create.steps.loading_tip_brand":"Ajusta tu marca para influir en las próximas publicaciones.","posts.create.steps.loading_tip_carousel":"Los carruseles generan una diapositiva por imagen solicitada.","posts.create.steps.loading_tip_quality":"La calidad balancea velocidad y costo.","posts.create.steps.create":"Crear post","posts.create.steps.back":"Atrás","posts.create.steps.next":"Continuar","posts.create.steps.cancel":"Cancelar","posts.create.steps.discard":"Descartar","posts.create.steps.retry":"Intentar de nuevo","posts.create.steps.no_platforms":"Sin cuentas conectadas","posts.create.steps.connect_first":"Conecta al menos una cuenta social para usar la generación con IA.","posts.create.steps.format.instagram_feed":"Post de Feed de Instagram","posts.create.steps.format.instagram_carousel":"Carrusel de Instagram","posts.create.steps.format.linkedin_post":"Post de LinkedIn","posts.create.steps.format.linkedin_page_post":"Post de Página de LinkedIn","posts.create.steps.format.x_post":"Post en X","posts.create.steps.format.bluesky_post":"Post en Bluesky","posts.create.steps.format.threads_post":"Post en Threads","posts.create.steps.format.mastodon_post":"Post en Mastodon","posts.create.steps.format.facebook_post":"Post en Facebook","posts.create.steps.format.pinterest_pin":"Pin de Pinterest","posts.create.steps.format.instagram_story":"Story de Instagram","posts.create.steps.format.facebook_story":"Story de Facebook","posts.templates.browser_title":"Elige una plantilla","posts.templates.browser_description":"Comienza con una plantilla curada y adáptala.","posts.templates.search_placeholder":"Buscar plantillas…","posts.templates.no_search_results":"Ninguna plantilla coincide con tu búsqueda","posts.templates.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.all_platforms":"Todas las plataformas","posts.templates.platform_search_placeholder":"Buscar plataforma…","posts.templates.no_platform_match":"Ninguna plataforma coincide.","posts.templates.use_this":"Usar esta plantilla","posts.templates.no_templates":"No hay plantillas disponibles.","posts.templates.applying":"Aplicando plantilla…","posts.templates.category.product_launch":"Lanzamiento de producto","posts.templates.category.promotion":"Promoción","posts.templates.category.educational":"Educativo","posts.templates.category.behind_the_scenes":"Detrás de cámaras","posts.templates.category.testimonial":"Testimonio","posts.templates.category.industry_tip":"Consejo del sector","posts.templates.category.event":"Evento","posts.templates.category.engagement":"Interacción","settings.title":"Configuración","settings.description":"Administra tu perfil y configuración de la cuenta","settings.hub.title":"Configuración","settings.hub.description":"Elige qué quieres gestionar.","settings.hub.profile.title":"Perfil","settings.hub.profile.description":"Actualiza tu información personal, contraseña y preferencias de notificaciones.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configura tu workspace, marca, miembros y claves de API.","settings.hub.account.title":"Cuenta","settings.hub.account.description":"Gestiona la información de la cuenta, uso y facturación.","settings.nav.profile":"Perfil","settings.nav.authentication":"Autenticación","settings.nav.workspace":"Workspace","settings.nav.members":"Miembros","settings.nav.notifications":"Notificaciones","settings.nav.billing":"Facturación","settings.notifications.title":"Preferencias de notificaciones","settings.notifications.heading":"Notificaciones por correo","settings.notifications.description":"Elige qué notificaciones por correo deseas recibir","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Recibir un correo cuando tu post se publique correctamente","settings.notifications.post_failed":"Post fallido","settings.notifications.post_failed_description":"Recibir un correo cuando tu post falle al publicar","settings.notifications.account_disconnected":"Cuenta desconectada","settings.notifications.account_disconnected_description":"Recibir un correo cuando una cuenta social se desconecte","settings.notifications.save":"Guardar preferencias","settings.profile.title":"Configuración del perfil","settings.profile.photo_heading":"Foto de perfil","settings.profile.photo_description":"Sube una foto de perfil","settings.profile.heading":"Información del perfil","settings.profile.description":"Actualiza tu nombre y correo electrónico","settings.profile.avatar":"Avatar","settings.profile.name":"Nombre","settings.profile.name_placeholder":"Nombre completo","settings.profile.email":"Correo electrónico","settings.profile.email_placeholder":"Correo electrónico","settings.profile.email_unverified":"Tu correo electrónico no ha sido verificado.","settings.profile.resend_verification":"Haz clic aquí para reenviar el correo de verificación.","settings.profile.verification_sent":"Se ha enviado un nuevo enlace de verificación a tu correo electrónico.","settings.profile.save":"Guardar","settings.authentication.title":"Autenticación","settings.authentication.page_title":"Configuración de autenticación","settings.authentication.sessions.title":"Sesiones activas","settings.authentication.sessions.description":"Si notas algo sospechoso, cierra sesión en otros dispositivos.","settings.authentication.sessions.unknown_browser":"Navegador desconocido","settings.authentication.sessions.unknown_ip":"IP desconocida","settings.authentication.sessions.on":"en","settings.authentication.sessions.active_now":"Activa ahora","settings.authentication.sessions.log_out_others":"Cerrar otras sesiones","settings.authentication.sessions.modal_title":"Cerrar otras sesiones","settings.authentication.sessions.modal_description_password":"Introduce tu contraseña actual para confirmar el cierre de las demás sesiones.","settings.authentication.sessions.modal_description_email":"Escribe tu correo electrónico para confirmar el cierre de las demás sesiones.","settings.authentication.sessions.password_placeholder":"Contraseña actual","settings.authentication.sessions.email_placeholder":"Tu correo","settings.authentication.sessions.cancel":"Cancelar","settings.authentication.sessions.submit":"Cerrar otras sesiones","settings.authentication.sessions.email_mismatch":"El correo electrónico no coincide con tu cuenta.","settings.authentication.sessions.flash_logged_out":"Has cerrado sesión en los demás dispositivos.","settings.authentication.password.update_title":"Actualizar contraseña","settings.authentication.password.set_title":"Definir una contraseña","settings.authentication.password.update_description":"Asegúrate de usar una contraseña larga y aleatoria para mantener tu cuenta segura.","settings.authentication.password.set_description":"Añade una contraseña para iniciar sesión sin un proveedor conectado.","settings.authentication.password.current_password":"Contraseña actual","settings.authentication.password.new_password":"Nueva contraseña","settings.authentication.password.confirm_password":"Confirmar contraseña","settings.authentication.password.save":"Guardar contraseña","settings.authentication.password.set":"Definir contraseña","settings.authentication.providers.title":"Cuentas conectadas","settings.authentication.providers.description":"Inicia sesión más rápido con estos proveedores conectados.","settings.authentication.providers.connected":"Conectada","settings.authentication.providers.not_connected":"No conectada","settings.authentication.providers.connect":"Conectar","settings.authentication.providers.disconnect":"Desconectar","settings.authentication.providers.flash_disconnected":":provider desconectada correctamente.","settings.authentication.providers.flash_connected":":provider conectada correctamente.","settings.authentication.providers.flash_already_linked":"Esa cuenta de :provider ya está vinculada a otro usuario.","settings.authentication.providers.flash_cannot_disconnect":"No puedes desconectar tu único método de inicio de sesión. Define una contraseña o conecta otro proveedor primero.","settings.delete_account.heading":"Eliminar cuenta","settings.delete_account.description":"Elimina tu cuenta y todos sus recursos","settings.delete_account.warning":"Advertencia","settings.delete_account.warning_message":"Procede con precaución, esta acción no se puede deshacer.","settings.delete_account.button":"Eliminar cuenta","settings.delete_account.modal_title":"¿Estás seguro de que deseas eliminar tu cuenta?","settings.delete_account.modal_description_password":"Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Introduce tu contraseña para confirmar.","settings.delete_account.modal_description_email":"Una vez eliminada, todos sus recursos y datos también se eliminarán permanentemente. Escribe tu correo :email para confirmar.","settings.delete_account.password":"Contraseña","settings.delete_account.password_placeholder":"Contraseña","settings.delete_account.email_placeholder":"Tu correo","settings.delete_account.email_mismatch":"El correo electrónico no coincide con tu cuenta.","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Eliminar cuenta","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Marca","settings.workspace.tabs.users":"Miembros","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Configuración del workspace","settings.workspace.logo_heading":"Logo del workspace","settings.workspace.logo_description":"Sube un logo para tu workspace","settings.workspace.heading":"Nombre del workspace","settings.workspace.description":"Actualiza el nombre del workspace","settings.workspace.members_heading":"Miembros","settings.workspace.members_description":"Administra miembros e invitaciones del workspace","settings.workspace.name":"Nombre","settings.workspace.name_placeholder":"Mi Workspace","settings.workspace.save":"Guardar","settings.brand.title":"Marca","settings.brand.description":"Configura la identidad de tu marca para el contenido generado por IA.","settings.brand.name":"Nombre del workspace","settings.brand.name_placeholder":"Mi marca","settings.brand.website":"Sitio web","settings.brand.website_placeholder":"https://tumarca.com","settings.brand.brand_description":"Descripción","settings.brand.brand_description_placeholder":"Cuéntanos sobre tu marca, lo que haces y quién es tu audiencia...","settings.brand.tone":"Tono de voz","settings.brand.tone_professional":"Profesional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Amigable","settings.brand.tone_bold":"Audaz","settings.brand.tone_inspirational":"Inspirador","settings.brand.tone_humorous":"Humorístico","settings.brand.tone_educational":"Educativo","settings.brand.voice_notes":"Notas de voz","settings.brand.voice_notes_placeholder":"Directrices adicionales de escritura, palabras a evitar, preferencias de estilo...","settings.brand.brand_color":"Color de marca","settings.brand.background_color":"Color de fondo","settings.brand.text_color":"Color de texto","settings.brand.font":"Fuente","settings.brand.image_style":"Estilo de imágenes","settings.brand.image_style_description":"Estilo visual aplicado al generar imágenes de diapositivas y portadas para publicaciones con IA.","settings.brand.image_style_cinematic":"Cinematográfico","settings.brand.image_style_illustration":"Ilustración","settings.brand.image_style_isometric_3d":"Isométrico","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Tipográfico","settings.brand.image_style_infographic":"Infográfico","settings.brand.image_style_minimalist":"Minimalista","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Idioma del contenido","settings.brand.content_language_description":"Idioma usado en los subtítulos, hashtags y cualquier texto dentro de imágenes o videos generados por IA.","settings.members.title":"Miembros","settings.members.heading":"Miembros del equipo","settings.members.description":"Administra miembros e invitaciones de este workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Eliminar","settings.members.make_admin":"Hacer administrador","settings.members.make_member":"Hacer miembro","settings.members.invite.title":"Invitar miembro","settings.members.invite.description":"Envía una invitación por correo para agregar colaboradores","settings.members.invite.email":"Correo electrónico","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Rol","settings.members.invite.role_placeholder":"Selecciona un rol","settings.members.invite.submit":"Enviar invitación","settings.members.pending.title":"Invitaciones pendientes","settings.members.pending.description":"Invitaciones en espera de aceptación","settings.members.pending.empty":"No hay invitaciones pendientes","settings.members.list.title":"Miembros","settings.members.list.description":"Personas con acceso a este workspace","settings.members.list.empty":"No hay miembros además del propietario","settings.members.remove_modal.title":"Eliminar miembro","settings.members.remove_modal.description":"¿Estás seguro de que deseas eliminar a este miembro del workspace? Perderá acceso a todos los recursos del workspace.","settings.members.remove_modal.action":"Eliminar miembro","settings.members.cancel_invite_modal.title":"Cancelar invitación","settings.members.cancel_invite_modal.description":"¿Estás seguro de que deseas cancelar esta invitación?","settings.members.cancel_invite_modal.action":"Cancelar invitación","settings.members.roles.owner":"Propietario","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Miembro","settings.members.roles.viewer":"Espectador","settings.members.flash.invite_sent":"¡Invitación enviada correctamente!","settings.members.flash.invite_deleted":"Invitación eliminada.","settings.members.flash.member_removed":"¡Miembro eliminado correctamente!","settings.members.flash.role_updated":"Rol del miembro actualizado.","settings.members.flash.wrong_email":"Esta invitación es para otro correo electrónico.","settings.members.flash.already_member":"Ya eres miembro de este workspace.","settings.members.flash.invite_accepted":"¡Bienvenido! Ahora eres miembro del workspace.","settings.members.flash.invite_declined":"Invitación rechazada.","settings.account.tabs.account":"Cuenta","settings.account.tabs.usage":"Uso","settings.account.tabs.billing":"Facturación","settings.account.title":"Configuración de cuenta","settings.account.description":"Gestiona el nombre de la cuenta y el correo de facturación","settings.account.name":"Nombre de la cuenta","settings.account.name_placeholder":"Mi Empresa","settings.account.billing_email":"Correo de facturación","settings.account.billing_email_placeholder":"facturacion@empresa.com","settings.account.billing_email_hint":"Este correo se usará para facturas y comunicaciones de facturación de Stripe.","settings.account.submit":"Guardar","settings.flash.account_updated":"¡Cuenta actualizada correctamente!","settings.flash.profile_updated":"¡Perfil actualizado correctamente!","settings.flash.language_updated":"¡Idioma actualizado correctamente!","settings.flash.password_updated":"¡Contraseña actualizada correctamente!","settings.flash.workspace_updated":"¡Configuración actualizada correctamente!","settings.flash.photo_updated":"¡Foto actualizada correctamente!","settings.flash.photo_deleted":"¡Foto eliminada correctamente!","settings.flash.logo_updated":"¡Logo subido correctamente!","settings.flash.logo_deleted":"¡Logo eliminado correctamente!","settings.flash.notifications_updated":"¡Preferencias de notificaciones actualizadas!","settings.api_keys.title":"Claves API","settings.api_keys.page_title":"Claves API","settings.api_keys.heading":"Claves API","settings.api_keys.description":"Administra claves API para acceso programático a tu workspace.","settings.api_keys.create":"Crear clave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Tu nueva clave API ha sido creada. Cópiala ahora — no podrás verla de nuevo.","settings.api_keys.table.name":"Nombre","settings.api_keys.table.key":"Clave","settings.api_keys.table.status":"Estado","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID de clave API","settings.api_keys.actions.copy_id_success":"ID de clave API copiado","settings.api_keys.actions.delete":"Eliminar","settings.api_keys.empty.title":"No hay claves API","settings.api_keys.empty.description":"Crea una clave API para acceder a tu workspace programáticamente.","settings.api_keys.delete_modal.title":"Eliminar clave API","settings.api_keys.delete_modal.description":"¿Estás seguro de que deseas eliminar esta clave API? Las aplicaciones que la usen perderán acceso inmediatamente.","settings.api_keys.delete_modal.action":"Eliminar clave API","settings.api_keys.create_dialog.title":"Crear clave API","settings.api_keys.create_dialog.description":"Crea una nueva clave API para acceso programático a tu workspace.","settings.api_keys.create_dialog.name":"Nombre","settings.api_keys.create_dialog.name_placeholder":"ej. Clave API de Producción","settings.api_keys.create_dialog.expires":"Fecha de expiración (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sin expiración","settings.api_keys.create_dialog.submit":"Crear","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"¡Clave API creada correctamente!","settings.api_keys.flash.deleted":"¡Clave API eliminada correctamente!","sidebar.workspaces":"Workspaces","sidebar.select_workspace":"Seleccionar workspace","sidebar.create_workspace":"Crear workspace","sidebar.create_post":"Crear post","sidebar.profile":"Perfil","sidebar.log_out":"Cerrar sesión","sidebar.workspace.connections":"Conexiones","sidebar.workspace.signatures":"Firmas","sidebar.workspace.labels":"Etiquetas","sidebar.workspace.assets":"Medios","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Seleccionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Oscuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Seleccionar","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Soporte","sidebar.analytics":"Analytics","sidebar.settings":"Configuración","sidebar.posts.calendar":"Calendario","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Programados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Borradores","sidebar.notifications":"Notificaciones","sidebar.mark_all_read":"Marcar todo como leído","sidebar.mark_as_read":"Marcar como leído","sidebar.archive_all":"Archivar todo","sidebar.no_notifications":"Sin notificaciones","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Dar feedback","sidebar.support.last_updates":"Últimas actualizaciones","sidebar.support.docs":"Documentación","signatures.title":"Firmas","signatures.description":"Crea firmas reutilizables para añadir rápidamente a tus posts","signatures.search":"Buscar firmas...","signatures.new":"Nueva firma","signatures.empty_title":"Aún no hay firmas","signatures.empty_description":"Crea firmas para añadir hashtags, links o cualquier texto reutilizable a tus posts","signatures.no_search_results":"Ninguna firma coincide con tu búsqueda","signatures.try_different_search":"Prueba otra palabra clave o limpia la búsqueda.","signatures.table.name":"Nombre","signatures.table.content":"Contenido","signatures.table.created_at":"Creado","signatures.actions.edit":"Editar firma","signatures.actions.delete":"Eliminar firma","signatures.create.title":"Crear firma","signatures.create.description":"Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).","signatures.create.name":"Nombre","signatures.create.name_placeholder":"ej: Marketing, Viaje, Cierre de marca","signatures.create.content":"Contenido","signatures.create.content_placeholder":"#marketing #socialmedia\nMás info: https://tumarca.com","signatures.create.content_hint":"Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.","signatures.create.submit":"Crear firma","signatures.create.submitting":"Creando...","signatures.edit.title":"Editar firma","signatures.edit.description":"Actualiza el nombre y el contenido de esta firma.","signatures.edit.name":"Nombre","signatures.edit.name_placeholder":"ej: Marketing, Viaje, Cierre de marca","signatures.edit.content":"Contenido","signatures.edit.content_placeholder":"#marketing #socialmedia\nMás info: https://tumarca.com","signatures.edit.content_hint":"Hashtags, links, intros, cierres — cualquier cosa que añades a los posts.","signatures.edit.submit":"Guardar cambios","signatures.edit.submitting":"Guardando...","signatures.delete.title":"Eliminar firma","signatures.delete.description":"¿Seguro que quieres eliminar esta firma? Esta acción no se puede deshacer.","signatures.delete.confirm":"Eliminar","signatures.delete.cancel":"Cancelar","signatures.flash.created":"Firma creada.","signatures.flash.updated":"Firma actualizada.","signatures.flash.deleted":"Firma eliminada.","usage.title":"Uso","usage.section_account":"Cuenta","usage.section_account_description":"Cuotas y límites de tu plan :plan.","usage.section_ai":"Créditos AI","usage.section_ai_description":"Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.","usage.workspaces":"Workspaces","usage.social_accounts":"Cuentas Sociales","usage.members":"Miembros","usage.credits":"Créditos","validation.accepted":"El campo :attribute debe ser aceptado.","validation.accepted_if":"El campo :attribute debe ser aceptado cuando :other es :value.","validation.active_url":"El campo :attribute debe ser una URL válida.","validation.after":"El campo :attribute debe ser una fecha posterior a :date.","validation.after_or_equal":"El campo :attribute debe ser una fecha posterior o igual a :date.","validation.alpha":"El campo :attribute solo puede contener letras.","validation.alpha_dash":"El campo :attribute solo puede contener letras, números, guiones y guiones bajos.","validation.alpha_num":"El campo :attribute solo puede contener letras y números.","validation.any_of":"El campo :attribute no es válido.","validation.array":"El campo :attribute debe ser un arreglo.","validation.ascii":"El campo :attribute solo puede contener caracteres alfanuméricos de un byte y símbolos.","validation.before":"El campo :attribute debe ser una fecha anterior a :date.","validation.before_or_equal":"El campo :attribute debe ser una fecha anterior o igual a :date.","validation.between.array":"El campo :attribute debe tener entre :min y :max elementos.","validation.between.file":"El campo :attribute debe pesar entre :min y :max kilobytes.","validation.between.numeric":"El campo :attribute debe estar entre :min y :max.","validation.between.string":"El campo :attribute debe tener entre :min y :max caracteres.","validation.boolean":"El campo :attribute debe ser verdadero o falso.","validation.can":"El campo :attribute contiene un valor no autorizado.","validation.confirmed":"La confirmación del campo :attribute no coincide.","validation.contains":"Al campo :attribute le falta un valor requerido.","validation.current_password":"La contraseña es incorrecta.","validation.date":"El campo :attribute debe ser una fecha válida.","validation.date_equals":"El campo :attribute debe ser una fecha igual a :date.","validation.date_format":"El campo :attribute debe coincidir con el formato :format.","validation.decimal":"El campo :attribute debe tener :decimal decimales.","validation.declined":"El campo :attribute debe ser rechazado.","validation.declined_if":"El campo :attribute debe ser rechazado cuando :other es :value.","validation.different":"El campo :attribute y :other deben ser diferentes.","validation.digits":"El campo :attribute debe tener :digits dígitos.","validation.digits_between":"El campo :attribute debe tener entre :min y :max dígitos.","validation.dimensions":"El campo :attribute tiene dimensiones de imagen no válidas.","validation.distinct":"El campo :attribute tiene un valor duplicado.","validation.doesnt_contain":"El campo :attribute no debe contener ninguno de los siguientes: :values.","validation.doesnt_end_with":"El campo :attribute no debe terminar con uno de los siguientes: :values.","validation.doesnt_start_with":"El campo :attribute no debe comenzar con uno de los siguientes: :values.","validation.email":"El campo :attribute debe ser un correo electrónico válido.","validation.encoding":"El campo :attribute debe estar codificado en :encoding.","validation.ends_with":"El campo :attribute debe terminar con uno de los siguientes: :values.","validation.enum":"El :attribute seleccionado no es válido.","validation.exists":"El :attribute seleccionado no es válido.","validation.extensions":"El campo :attribute debe tener una de las siguientes extensiones: :values.","validation.file":"El campo :attribute debe ser un archivo.","validation.filled":"El campo :attribute debe tener un valor.","validation.gt.array":"El campo :attribute debe tener más de :value elementos.","validation.gt.file":"El campo :attribute debe pesar más de :value kilobytes.","validation.gt.numeric":"El campo :attribute debe ser mayor que :value.","validation.gt.string":"El campo :attribute debe tener más de :value caracteres.","validation.gte.array":"El campo :attribute debe tener :value elementos o más.","validation.gte.file":"El campo :attribute debe pesar :value kilobytes o más.","validation.gte.numeric":"El campo :attribute debe ser mayor o igual a :value.","validation.gte.string":"El campo :attribute debe tener :value caracteres o más.","validation.hex_color":"El campo :attribute debe ser un color hexadecimal válido.","validation.image":"El campo :attribute debe ser una imagen.","validation.in":"El :attribute seleccionado no es válido.","validation.in_array":"El campo :attribute debe existir en :other.","validation.in_array_keys":"El campo :attribute debe contener al menos una de las siguientes claves: :values.","validation.integer":"El campo :attribute debe ser un número entero.","validation.ip":"El campo :attribute debe ser una dirección IP válida.","validation.ipv4":"El campo :attribute debe ser una dirección IPv4 válida.","validation.ipv6":"El campo :attribute debe ser una dirección IPv6 válida.","validation.json":"El campo :attribute debe ser una cadena JSON válida.","validation.list":"El campo :attribute debe ser una lista.","validation.lowercase":"El campo :attribute debe estar en minúsculas.","validation.lt.array":"El campo :attribute debe tener menos de :value elementos.","validation.lt.file":"El campo :attribute debe pesar menos de :value kilobytes.","validation.lt.numeric":"El campo :attribute debe ser menor que :value.","validation.lt.string":"El campo :attribute debe tener menos de :value caracteres.","validation.lte.array":"El campo :attribute no debe tener más de :value elementos.","validation.lte.file":"El campo :attribute debe pesar :value kilobytes o menos.","validation.lte.numeric":"El campo :attribute debe ser menor o igual a :value.","validation.lte.string":"El campo :attribute debe tener :value caracteres o menos.","validation.mac_address":"El campo :attribute debe ser una dirección MAC válida.","validation.max.array":"El campo :attribute no debe tener más de :max elementos.","validation.max.file":"El campo :attribute no debe pesar más de :max kilobytes.","validation.max.numeric":"El campo :attribute no debe ser mayor que :max.","validation.max.string":"El campo :attribute no debe tener más de :max caracteres.","validation.max_digits":"El campo :attribute no debe tener más de :max dígitos.","validation.mimes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.mimetypes":"El campo :attribute debe ser un archivo de tipo: :values.","validation.min.array":"El campo :attribute debe tener al menos :min elementos.","validation.min.file":"El campo :attribute debe pesar al menos :min kilobytes.","validation.min.numeric":"El campo :attribute debe ser al menos :min.","validation.min.string":"El campo :attribute debe tener al menos :min caracteres.","validation.min_digits":"El campo :attribute debe tener al menos :min dígitos.","validation.missing":"El campo :attribute debe estar ausente.","validation.missing_if":"El campo :attribute debe estar ausente cuando :other es :value.","validation.missing_unless":"El campo :attribute debe estar ausente a menos que :other sea :value.","validation.missing_with":"El campo :attribute debe estar ausente cuando :values está presente.","validation.missing_with_all":"El campo :attribute debe estar ausente cuando :values están presentes.","validation.multiple_of":"El campo :attribute debe ser múltiplo de :value.","validation.not_in":"El :attribute seleccionado no es válido.","validation.not_regex":"El formato del campo :attribute no es válido.","validation.numeric":"El campo :attribute debe ser un número.","validation.password.letters":"El campo :attribute debe contener al menos una letra.","validation.password.mixed":"El campo :attribute debe contener al menos una letra mayúscula y una minúscula.","validation.password.numbers":"El campo :attribute debe contener al menos un número.","validation.password.symbols":"El campo :attribute debe contener al menos un símbolo.","validation.password.uncompromised":"El :attribute proporcionado ha aparecido en una filtración de datos. Elige un :attribute diferente.","validation.present":"El campo :attribute debe estar presente.","validation.present_if":"El campo :attribute debe estar presente cuando :other es :value.","validation.present_unless":"El campo :attribute debe estar presente a menos que :other sea :value.","validation.present_with":"El campo :attribute debe estar presente cuando :values está presente.","validation.present_with_all":"El campo :attribute debe estar presente cuando :values están presentes.","validation.prohibited":"El campo :attribute está prohibido.","validation.prohibited_if":"El campo :attribute está prohibido cuando :other es :value.","validation.prohibited_if_accepted":"El campo :attribute está prohibido cuando :other es aceptado.","validation.prohibited_if_declined":"El campo :attribute está prohibido cuando :other es rechazado.","validation.prohibited_unless":"El campo :attribute está prohibido a menos que :other esté en :values.","validation.prohibits":"El campo :attribute prohíbe que :other esté presente.","validation.regex":"El formato del campo :attribute no es válido.","validation.required":"El campo :attribute es obligatorio.","validation.required_array_keys":"El campo :attribute debe contener entradas para: :values.","validation.required_if":"El campo :attribute es obligatorio cuando :other es :value.","validation.required_if_accepted":"El campo :attribute es obligatorio cuando :other es aceptado.","validation.required_if_declined":"El campo :attribute es obligatorio cuando :other es rechazado.","validation.required_unless":"El campo :attribute es obligatorio a menos que :other esté en :values.","validation.required_with":"El campo :attribute es obligatorio cuando :values está presente.","validation.required_with_all":"El campo :attribute es obligatorio cuando :values están presentes.","validation.required_without":"El campo :attribute es obligatorio cuando :values no está presente.","validation.required_without_all":"El campo :attribute es obligatorio cuando ninguno de :values está presente.","validation.same":"El campo :attribute debe coincidir con :other.","validation.size.array":"El campo :attribute debe contener :size elementos.","validation.size.file":"El campo :attribute debe pesar :size kilobytes.","validation.size.numeric":"El campo :attribute debe ser :size.","validation.size.string":"El campo :attribute debe tener :size caracteres.","validation.starts_with":"El campo :attribute debe comenzar con uno de los siguientes: :values.","validation.string":"El campo :attribute debe ser una cadena de texto.","validation.timezone":"El campo :attribute debe ser una zona horaria válida.","validation.unique":"El :attribute ya ha sido registrado.","validation.uploaded":"El :attribute no se pudo subir.","validation.uppercase":"El campo :attribute debe estar en mayúsculas.","validation.url":"El campo :attribute debe ser una URL válida.","validation.ulid":"El campo :attribute debe ser un ULID válido.","validation.uuid":"El campo :attribute debe ser un UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Tus workspaces","workspaces.select_description":"Selecciona un workspace para continuar","workspaces.current":"Actual","workspaces.connections":":count conexiones","workspaces.posts":":count posts","workspaces.create.page_title":"Crea tu workspace","workspaces.create.title":"Configura tu workspace","workspaces.create.description":"Cuéntanos un poco sobre ti o tu proyecto. Lo usaremos para personalizar las publicaciones generadas por IA con tu voz.","workspaces.create.website":"Sitio web","workspaces.create.website_placeholder":"https://tumarca.com","workspaces.create.autofill":"Autocompletar desde el sitio","workspaces.create.autofill_missing_url":"Ingresa una URL primero.","workspaces.create.autofill_success":"Información de la marca cargada.","workspaces.create.autofill_error":"No se pudo autocompletar. Puedes llenar los campos manualmente.","workspaces.create.autofill_errors.unreachable":"No pudimos acceder a ese sitio web (:reason).","workspaces.create.autofill_errors.http_status":"El sitio web devolvió un estado inesperado (:status).","workspaces.create.autofill_errors.invalid_scheme":"Solo se admiten URLs http y https.","workspaces.create.autofill_errors.missing_host":"A la URL le falta un host.","workspaces.create.autofill_errors.unresolvable_host":"No pudimos resolver el host (:host).","workspaces.create.autofill_errors.private_network":"No se permiten URLs que apunten a redes privadas.","workspaces.create.logo_captured":"Logo capturado de tu sitio.","workspaces.create.name":"Nombre del workspace","workspaces.create.name_placeholder":"ej. Acme Inc","workspaces.create.brand_description":"Descripción de la marca","workspaces.create.brand_description_placeholder":"¿Qué hace tu marca?","workspaces.create.tone":"Tono de la marca","workspaces.create.tone_professional":"Profesional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Amigable","workspaces.create.tone_bold":"Audaz","workspaces.create.tone_inspirational":"Inspirador","workspaces.create.tone_humorous":"Humorístico","workspaces.create.tone_educational":"Educativo","workspaces.create.content_language":"Idioma del contenido","workspaces.create.content_language_description":"Las descripciones generadas por IA se escribirán en este idioma.","workspaces.create.voice_notes":"Notas de voz (opcional)","workspaces.create.voice_notes_placeholder":"ej. frases cortas y directas. evita jerga.","workspaces.create.brand_color":"Color de marca","workspaces.create.background_color":"Color de fondo","workspaces.create.text_color":"Color de texto","workspaces.create.submit":"Crear workspace","workspaces.create.success":"Workspace creado. Conecta una cuenta social para empezar a publicar.","workspaces.limit_reached":"Has alcanzado el límite de workspaces de tu plan.","workspaces.flash.deleted":"Workspace eliminado correctamente."} \ No newline at end of file diff --git a/lang/php_pt-BR.json b/lang/php_pt-BR.json index c93dfe24..9d575455 100644 --- a/lang/php_pt-BR.json +++ b/lang/php_pt-BR.json @@ -1 +1 @@ -{"accounts.title":"Conexões","accounts.page_title":"Contas Sociais","accounts.description":"Visão geral de todas as suas contas sociais conectadas","accounts.add_social":"Adicionar Rede Social","accounts.add_social_title":"Conectar uma Conta Social","accounts.add_social_description":"Conecte uma conta social ao TryPost para começar a publicar","accounts.connect_cta":"Conectar","accounts.no_accounts":"Nenhuma conta conectada ainda","accounts.no_accounts_description":"Conecte suas redes sociais para começar a agendar e publicar posts","accounts.no_search_results":"Nenhuma conta corresponde à sua busca","accounts.try_different_search":"Tente outra palavra-chave ou limpe a busca.","accounts.search":"Buscar contas...","accounts.added":"Adicionada :date","accounts.limit_reached":"Você atingiu o limite de contas sociais do seu plano.","accounts.not_connected":"Não conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexão perdida","accounts.reconnect_account":"Reconectar conta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.table.account":"Conta","accounts.table.platform":"Plataforma","accounts.table.status":"Status","accounts.table.last_used":"Último uso","accounts.table.added":"Adicionada","accounts.table.active":"Ativa","accounts.never_used":"Nunca usada","accounts.status.connected":"Conectada","accounts.status.disconnected":"Desconectada","accounts.descriptions.linkedin":"Conecte seu perfil pessoal do LinkedIn","accounts.descriptions.linkedin-page":"Conecte uma página de empresa do LinkedIn","accounts.descriptions.x":"Conecte sua conta do X (Twitter)","accounts.descriptions.tiktok":"Conecte sua conta do TikTok","accounts.descriptions.youtube":"Conecte um canal do YouTube","accounts.descriptions.facebook":"Conecte uma página do Facebook","accounts.descriptions.instagram":"Conecte uma conta profissional do Instagram","accounts.descriptions.instagram-facebook":"Conecte Instagram via página do Facebook","accounts.descriptions.threads":"Conecte sua conta do Threads","accounts.descriptions.pinterest":"Conecte sua conta do Pinterest","accounts.descriptions.bluesky":"Conecte sua conta do Bluesky","accounts.descriptions.mastodon":"Conecte sua conta do Mastodon","accounts.disconnect_modal.title":"Desconectar Conta","accounts.disconnect_modal.description":"Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Digite suas credenciais para conectar","accounts.bluesky.email":"E-mail","accounts.bluesky.email_placeholder":"seuhandle.bsky.social","accounts.bluesky.app_password":"Senha do App","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use uma Senha do App por segurança. Crie uma em bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Digite a instância do seu Mastodon","accounts.mastodon.instance_url":"URL da Instância","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Digite a URL da sua instância Mastodon (ex: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar com Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Selecionar Página do Facebook","accounts.facebook.description":"Escolha qual página você deseja conectar","accounts.facebook.no_pages":"Nenhuma página encontrada","accounts.facebook.no_pages_description":"Você não é administrador de nenhuma página do Facebook.","accounts.facebook.page_label":"Página do Facebook","accounts.instagram_facebook.title":"Selecionar Conta do Instagram","accounts.instagram_facebook.description":"Escolha qual conta do Instagram você deseja conectar","accounts.instagram_facebook.no_pages":"Nenhuma conta do Instagram encontrada","accounts.instagram_facebook.no_pages_description":"Nenhuma Página do Facebook com conta Instagram Business vinculada foi encontrada.","accounts.linkedin.title":"Selecionar Página do LinkedIn","accounts.linkedin.description":"Escolha qual página você deseja conectar","accounts.linkedin.no_pages":"Nenhuma página encontrada","accounts.linkedin.no_pages_description":"Você não é administrador de nenhuma página do LinkedIn.","accounts.linkedin.page_label":"Página do LinkedIn","accounts.flash.disconnected":"Conta desconectada com sucesso!","accounts.flash.connected":"Conta conectada com sucesso!","accounts.flash.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.flash.workspace_not_found":"Workspace não encontrado.","accounts.flash.activated":"Conta ativada!","accounts.flash.deactivated":"Conta desativada!","accounts.flash.already_connected":"Esta plataforma já está conectada.","accounts.flash.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","accounts.popup_callback.title_success":"Conectado","accounts.popup_callback.title_error":"Erro","accounts.popup_callback.closing":"Esta janela será fechada automaticamente...","accounts.popup_callback.close_now":"Você pode fechar esta janela agora.","accounts.popup_callback.connected":"Conta conectada!","accounts.popup_callback.reconnected":"Conta reconectada!","accounts.popup_callback.error_connecting":"Erro ao conectar conta. Por favor, tente novamente.","accounts.popup_callback.error_connecting_page":"Erro ao conectar página. Por favor, tente novamente.","accounts.popup_callback.error_connecting_channel":"Erro ao conectar canal. Por favor, tente novamente.","accounts.popup_callback.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.popup_callback.workspace_not_found":"Workspace não encontrado.","accounts.popup_callback.invalid_state":"Estado inválido. Por favor, tente novamente.","accounts.popup_callback.failed_to_authenticate":"Falha na autenticação.","accounts.popup_callback.failed_to_get_profile":"Falha ao obter perfil.","accounts.popup_callback.page_not_found":"Página não encontrada.","accounts.popup_callback.channel_not_found":"Canal não encontrado.","accounts.popup_callback.no_facebook_pages":"Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.","accounts.popup_callback.no_facebook_instagram_pages":"Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.","accounts.popup_callback.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","accounts.popup_callback.not_linkedin_admin":"Você não é administrador de nenhuma página do LinkedIn.","analytics.no_accounts":"Nenhuma conta conectada com analytics.","analytics.no_accounts_match":"Nenhuma conta corresponde.","analytics.search_account":"Buscar conta…","analytics.select_account":"Selecione uma conta para ver analytics.","analytics.no_data":"Nenhum dado de analytics disponível.","analytics.metrics.avg_view_duration":"Duração Média (s)","analytics.metrics.avg_view_percentage":"Visualização Média","analytics.metrics.bookmarks":"Salvos","analytics.metrics.clicks":"Cliques","analytics.metrics.comments":"Comentários","analytics.metrics.engagement":"Engajamento","analytics.metrics.favourites":"Favoritos","analytics.metrics.followers":"Seguidores","analytics.metrics.following":"Seguindo","analytics.metrics.impressions":"Impressões","analytics.metrics.interactions":"Interações","analytics.metrics.likes":"Curtidas","analytics.metrics.minutes_watched":"Minutos Assistidos","analytics.metrics.organic_followers":"Seguidores Orgânicos","analytics.metrics.outbound_clicks":"Cliques Externos","analytics.metrics.page_followers":"Seguidores da Página","analytics.metrics.page_reach":"Alcance da Página","analytics.metrics.page_views":"Visualizações da Página","analytics.metrics.paid_followers":"Seguidores Pagos","analytics.metrics.pin_click_rate":"Taxa de Clique em Pins","analytics.metrics.pin_clicks":"Cliques em Pins","analytics.metrics.posts_engagement":"Engajamento dos Posts","analytics.metrics.posts_reach":"Alcance dos Posts","analytics.metrics.quotes":"Citações","analytics.metrics.reach":"Alcance","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Comentários Recentes","analytics.metrics.recent_likes":"Curtidas Recentes","analytics.metrics.recent_shares":"Compartilhamentos Recentes","analytics.metrics.replies":"Respostas","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Salvos","analytics.metrics.shares":"Compartilhamentos","analytics.metrics.subscribers_gained":"Inscritos Ganhos","analytics.metrics.subscribers_lost":"Inscritos Perdidos","analytics.metrics.total_likes":"Curtidas Totais","analytics.metrics.video_views":"Visualizações de Vídeo","analytics.metrics.videos":"Vídeos","analytics.metrics.views":"Visualizações","assets.title":"Mídias","assets.tabs.my_uploads":"Meus uploads","assets.tabs.stock_photos":"Fotos gratuitas","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Arraste e solte seus arquivos aqui ou clique para selecionar","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Enviando...","assets.empty.title":"Nenhuma mídia ainda","assets.empty.description":"Envie imagens e vídeos para criar sua biblioteca de mídia.","assets.save_to_assets":"Salvar na biblioteca","assets.saved":"Salvo na sua biblioteca!","assets.create_post":"Criar post","assets.add_to_post":"Adicionar ao post","assets.search_placeholder":"Buscar mídia...","assets.delete.title":"Excluir mídia","assets.delete.description":"Tem certeza que deseja excluir esta mídia? Esta ação não pode ser desfeita.","assets.delete.confirm":"Excluir","assets.delete.cancel":"Cancelar","assets.unsplash.search_placeholder":"Buscar fotos gratuitas...","assets.unsplash.no_results":"Nenhuma foto encontrada","assets.unsplash.no_results_description":"Tente outro termo de busca.","assets.unsplash.trending":"Em alta no Unsplash","assets.unsplash.start_searching":"Busque fotos gratuitas do Unsplash","assets.giphy.trending":"Em alta no Giphy","assets.giphy.search_placeholder":"Buscar GIFs...","assets.giphy.no_results":"Nenhum GIF encontrado","assets.giphy.no_results_description":"Tente outro termo de busca.","assets.giphy.powered_by":"Powered by GIPHY","auth.failed":"Essas credenciais não correspondem aos nossos registros.","auth.password":"A senha fornecida está incorreta.","auth.throttle":"Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.","auth.flash.welcome":"Bem-vindo ao TryPost!","auth.flash.welcome_trial":"Bem-vindo ao TryPost! Seu período de teste começou.","auth.legal":"Ao continuar, você concorda com nossos Termos de Serviço e Política de Privacidade.","auth.slides.calendar.title":"Calendário Visual","auth.slides.calendar.description":"Planeje e agende seu conteúdo com um calendário intuitivo de arrastar e soltar em todas as suas contas sociais.","auth.slides.scheduling.title":"Agendamento Inteligente","auth.slides.scheduling.description":"Agende posts no LinkedIn, X, Instagram, TikTok, YouTube e mais — tudo em um só lugar.","auth.slides.media.title":"Mídia Rica","auth.slides.media.description":"Publique imagens, carrosséis, stories e reels. Cada plataforma recebe o formato correto automaticamente.","auth.slides.video.title":"Publicação de Vídeo","auth.slides.video.description":"Envie vídeos uma vez e publique no TikTok, YouTube Shorts, Instagram Reels e Facebook Reels.","auth.slides.team.title":"Workspaces em Equipe","auth.slides.team.description":"Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.","auth.slides.signatures.title":"Assinaturas","auth.slides.signatures.description":"Salve assinaturas reutilizáveis (hashtags, links, encerramentos) e anexe nos posts com um clique.","auth.or_continue_with":"Ou continue com","auth.google_login":"Entrar com Google","auth.google_signup":"Cadastrar com Google","auth.github_login":"Entrar com GitHub","auth.github_signup":"Cadastrar com GitHub","auth.github_email_unavailable":"Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.","auth.signup_success.page_title":"Bem-vindo","auth.signup_success.title":"Configurando sua conta","auth.signup_success.description":"Isso geralmente leva apenas alguns segundos...","auth.login.title":"Entrar na sua conta","auth.login.description":"Digite seu email e senha abaixo para entrar","auth.login.page_title":"Entrar","auth.login.email":"Endereço de email","auth.login.password":"Senha","auth.login.forgot_password":"Esqueceu a senha?","auth.login.remember_me":"Lembrar de mim","auth.login.submit":"Entrar","auth.login.no_account":"Não tem uma conta?","auth.login.sign_up":"Cadastre-se","auth.register.title":"Criar uma conta","auth.register.description":"Digite seus dados abaixo para criar sua conta","auth.register.page_title":"Cadastro","auth.register.name":"Nome","auth.register.name_placeholder":"Nome completo","auth.register.email":"Endereço de email","auth.register.password":"Senha","auth.register.show_password":"Mostrar senha","auth.register.hide_password":"Esconder senha","auth.register.submit":"Criar conta","auth.register.has_account":"Já tem uma conta?","auth.register.log_in":"Entrar","auth.forgot_password.title":"Esqueceu a senha","auth.forgot_password.description":"Digite seu email para receber um link de redefinição de senha","auth.forgot_password.page_title":"Esqueceu a senha","auth.forgot_password.email":"Endereço de email","auth.forgot_password.submit":"Enviar link de redefinição","auth.forgot_password.return_to":"Ou, volte para","auth.forgot_password.log_in":"entrar","auth.reset_password.title":"Redefinir senha","auth.reset_password.description":"Por favor, digite sua nova senha abaixo","auth.reset_password.page_title":"Redefinir senha","auth.reset_password.email":"Email","auth.reset_password.password":"Senha","auth.reset_password.confirm_password":"Confirmar Senha","auth.reset_password.confirm_placeholder":"Confirmar senha","auth.reset_password.submit":"Redefinir senha","auth.verify_email.title":"Verificar email","auth.verify_email.description":"Por favor, verifique seu endereço de email clicando no link que acabamos de enviar.","auth.verify_email.page_title":"Verificação de email","auth.verify_email.link_sent":"Um novo link de verificação foi enviado para o endereço de email que você forneceu durante o cadastro.","auth.verify_email.resend":"Reenviar email de verificação","auth.verify_email.log_out":"Sair","auth.accept_invite.page_title":"Aceitar Convite","auth.accept_invite.title":"Você foi convidado!","auth.accept_invite.description":"Você foi convidado para participar do workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Seu cargo","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Aceitar Convite","auth.accept_invite.decline":"Recusar Convite","auth.accept_invite.login_prompt":"Entre ou crie uma conta para aceitar este convite.","auth.accept_invite.log_in":"Entrar","auth.accept_invite.create_account":"Criar Conta","billing.title":"Faturamento","billing.upgrade_dialog.title":"Faça upgrade do seu plano","billing.upgrade_dialog.description":"Escolha um plano que se encaixe nas suas necessidades.","billing.upgrade_dialog.current_plan":"Plano atual","billing.upgrade_dialog.current_short":"Atual","billing.upgrade_dialog.current_badge":"Atual","billing.upgrade_dialog.subscribe":"Assinar","billing.upgrade_dialog.switch":"Mudar para este plano","billing.upgrade_dialog.switch_short":"Mudar","billing.upgrade_dialog.switch_to_yearly":"Mudar para anual","billing.upgrade_dialog.switch_to_monthly":"Mudar para mensal","billing.upgrade_dialog.unavailable":"Indisponível","billing.upgrade_dialog.reasons.workspace_limit":"Você atingiu o limite de workspaces do seu plano. Faça upgrade pra criar mais.","billing.upgrade_dialog.reasons.social_account_limit":"Você atingiu o limite de contas sociais do seu plano. Faça upgrade pra conectar mais.","billing.upgrade_dialog.reasons.member_limit":"Você atingiu o limite de membros do seu plano. Faça upgrade pra convidar mais pessoas.","billing.subscribe.page_title":"Escolha seu plano","billing.subscribe.eyebrow":"Preços","billing.subscribe.title":"Escolha o plano ideal pra você","billing.subscribe.description":"Escolha o plano que combina com você. Cobrança mensal ou anual.","billing.subscribe.monthly":"Mensal","billing.subscribe.yearly":"Anual","billing.subscribe.per_month":"mensal","billing.subscribe.per_year":"anual","billing.subscribe.billed_monthly":"Cobrança mensal","billing.subscribe.billed_yearly":"Cobrança anual","billing.subscribe.features_included":"O que está incluído:","billing.subscribe.everything_in":"Tudo do :plan, mais:","billing.subscribe.save_months":"2 meses grátis","billing.subscribe.popular":"Mais popular","billing.subscribe.subscribe_cta":"Assinar","billing.subscribe.prices.starter.monthly":"R$ 95","billing.subscribe.prices.starter.yearly_per_month":"R$ 79","billing.subscribe.prices.starter.yearly":"R$ 950","billing.subscribe.prices.plus.monthly":"R$ 145","billing.subscribe.prices.plus.yearly_per_month":"R$ 121","billing.subscribe.prices.plus.yearly":"R$ 1450","billing.subscribe.prices.pro.monthly":"R$ 245","billing.subscribe.prices.pro.yearly_per_month":"R$ 204","billing.subscribe.prices.pro.yearly":"R$ 2450","billing.subscribe.prices.max.monthly":"R$ 495","billing.subscribe.prices.max.yearly_per_month":"R$ 413","billing.subscribe.prices.max.yearly":"R$ 4950","billing.subscribe.features.social_accounts":":count contas sociais","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count membros da equipe","billing.subscribe.features.credits":":count créditos IA/mês","billing.subscribe.credit_tooltips.starter":"Em média 150 posts de tamanho médio + 5 imagens de IA por mês.","billing.subscribe.credit_tooltips.plus":"Em média 300 posts de tamanho médio + 10 imagens de IA por mês.","billing.subscribe.credit_tooltips.pro":"Em média 700 posts de tamanho médio + 30 imagens de IA por mês.","billing.subscribe.credit_tooltips.max":"Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.","billing.plan.title":"Plano","billing.plan.description":"Gerencie seu plano de assinatura.","billing.plan.change":"Mudar plano","billing.plan.label":"Plano","billing.plan.price":"Preço","billing.plan.month":"mês","billing.plan.trial":"Trial","billing.plan.active":"Ativo","billing.plan.past_due":"Vencido","billing.plan.cancelling":"Cancelando","billing.plan.trial_ends":"Teste termina em","billing.subscription.title":"Assinatura","billing.subscription.description":"Gerencie seu método de pagamento, dados de cobrança e assinatura.","billing.subscription.payment_method":"Método de pagamento","billing.subscription.no_payment_method":"Nenhum método de pagamento cadastrado.","billing.subscription.expires_on":"Expira em :month/:year","billing.subscription.manage_label":"Assinatura","billing.subscription.manage_stripe":"Gerenciar no Stripe","billing.invoices.title":"Faturas","billing.invoices.description":"Baixe suas faturas anteriores.","billing.invoices.empty":"Nenhuma fatura encontrada","billing.invoices.paid":"Pago","billing.flash.plan_changed":"Você está agora no plano :plan.","billing.flash.cannot_manage":"Apenas o owner da conta pode gerenciar a cobrança.","billing.flash.cannot_downgrade.workspaces":"Não é possível mudar para :plan: você tem :count workspaces mas o plano só permite :limit.","billing.flash.cannot_downgrade.social_accounts":"Não é possível mudar para :plan: você tem :count contas sociais mas o plano só permite :limit.","billing.flash.cannot_downgrade.members":"Não é possível mudar para :plan: você tem :count membros (incluindo convites) mas o plano só permite :limit.","billing.flash.credits_exhausted":"Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.","billing.processing.page_title":"Processando...","billing.processing.title":"Processando sua assinatura","billing.processing.description":"Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.","billing.processing.success_title":"Tudo pronto!","billing.processing.success_description":"Sua assinatura está ativa. Redirecionando para seus workspaces...","billing.processing.cancelled_title":"Pagamento cancelado","billing.processing.cancelled_description":"Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.","billing.processing.retry":"Tentar novamente","brands.new_brand":"Nova Marca","brands.no_brands_yet":"Nenhuma marca ainda","brands.no_brands_description":"Crie marcas para organizar suas contas de redes sociais por cliente ou projeto","brands.accounts_count":":count contas","brands.create.title":"Criar Marca","brands.create.description":"Dê um nome à sua marca para agrupar contas de redes sociais","brands.create.name":"Nome da Marca","brands.create.name_placeholder":"ex. Acme Corp, Pessoal","brands.create.submit":"Criar Marca","brands.create.submitting":"Criando...","brands.edit.title":"Editar Marca","brands.edit.description":"Atualize o nome desta marca","brands.edit.name":"Nome da Marca","brands.edit.name_placeholder":"ex. Acme Corp, Pessoal","brands.edit.submit":"Salvar Alterações","brands.edit.submitting":"Salvando...","brands.delete.title":"Excluir Marca","brands.delete.description":"Tem certeza de que deseja excluir esta marca? As contas de redes sociais serão desvinculadas mas não excluídas.","brands.delete.confirm":"Excluir","brands.delete.cancel":"Cancelar","brands.flash.created":"Marca criada com sucesso!","brands.flash.updated":"Marca atualizada com sucesso!","brands.flash.deleted":"Marca excluída com sucesso!","calendar.title":"Calendário","calendar.today":"Hoje","calendar.day":"Dia","calendar.week":"Semana","calendar.month":"Mês","calendar.new_post":"Novo Post","calendar.no_content":"Sem conteúdo","calendar.more":"+:count mais","comments.placeholder":"Escreva um comentário...","comments.reply_placeholder":"Escreva uma resposta...","comments.reply":"Responder","comments.edit":"Editar","comments.delete":"Excluir","comments.edited":"editado","comments.save":"Salvar","comments.cancel":"Cancelar","comments.send":"Enviar","comments.replying_to":"Respondendo a :name","comments.empty":"Nenhum comentário ainda. Comece a conversa.","comments.load_more":"Carregar comentários antigos","comments.today":"Hoje","comments.yesterday":"Ontem","common.confirm_modal.cannot_be_undone":"Esta ação não pode ser desfeita.","common.confirm_modal.type":"Digite","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar para a área de transferência","common.confirm_modal.delete_keyword":"deletar","common.photo_upload.upload":"Enviar","common.photo_upload.uploading":"Enviando...","common.photo_upload.remove":"Remover foto","common.photo_upload.hint":"Recomendado: imagem quadrada, máximo 2 MB.","common.timezone.select":"Selecionar fuso horário","common.timezone.search":"Buscar fuso horário...","common.timezone.empty":"Fuso horário não encontrado","common.date_picker.select":"Selecionar data","common.date_range_picker.placeholder":"Escolha um período","common.date_range_picker.today":"Hoje","common.date_range_picker.yesterday":"Ontem","common.date_range_picker.last_7_days":"Últimos 7 dias","common.date_range_picker.last_30_days":"Últimos 30 dias","common.date_range_picker.last_3_months":"Últimos 3 meses","common.date_range_picker.last_6_months":"Últimos 6 meses","common.date_range_picker.last_12_months":"Últimos 12 meses","common.date_range_picker.this_month":"Este mês","common.date_range_picker.last_month":"Mês passado","common.date_range_picker.year_to_date":"Desde o início do ano","common.date_range_picker.last_year":"Ano passado","common.cancel":"Cancelar","common.clear":"Limpar","common.close":"Fechar","common.loading_more":"Carregando mais...","labels.title":"Etiquetas","labels.description":"Crie etiquetas para organizar e categorizar seus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nova Etiqueta","labels.no_labels_yet":"Nenhuma etiqueta ainda","labels.no_search_results":"Nenhuma etiqueta corresponde à sua busca","labels.try_different_search":"Tente outra palavra-chave ou limpe a busca.","labels.create_first_label":"Crie sua primeira etiqueta","labels.table.name":"Nome","labels.table.created_at":"Criado","labels.actions.edit":"Editar etiqueta","labels.actions.delete":"Excluir etiqueta","labels.create.title":"Criar Etiqueta","labels.create.description":"Dê um nome e escolha uma cor para sua etiqueta","labels.create.name":"Nome","labels.create.name_placeholder":"Digite o nome da etiqueta...","labels.create.color":"Cor","labels.create.submit":"Criar Etiqueta","labels.create.submitting":"Criando...","labels.edit.title":"Editar Etiqueta","labels.edit.description":"Atualize o nome e a cor desta etiqueta","labels.edit.name":"Nome","labels.edit.name_placeholder":"Digite o nome da etiqueta...","labels.edit.color":"Cor","labels.edit.submit":"Salvar Alterações","labels.edit.submitting":"Salvando...","labels.delete.title":"Excluir Etiqueta","labels.delete.description":"Tem certeza que deseja excluir esta etiqueta? Esta ação não pode ser desfeita.","labels.delete.confirm":"Excluir","labels.delete.cancel":"Cancelar","labels.flash.created":"Etiqueta criada com sucesso!","labels.flash.updated":"Etiqueta atualizada com sucesso!","labels.flash.deleted":"Etiqueta excluída com sucesso!","mail.mentioned.subject":":name mencionou você no TryPost","mail.mentioned.title":":name mencionou você","mail.mentioned.intro":":name mencionou você num comentário.","mail.mentioned.cta":"Ver comentário","mail.workspace_connections_disconnected.subject":"{1} :count conta precisa ser reconectada em :workspace|[2,*] :count contas precisam ser reconectadas em :workspace","mail.workspace_connections_disconnected.title":"Contas Precisam ser Reconectadas","mail.workspace_connections_disconnected.intro":"As seguintes contas de redes sociais no seu workspace :workspace foram desconectadas e precisam ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Isso pode ter acontecido porque:","mail.workspace_connections_disconnected.reason_expired":"Os tokens de acesso expiraram","mail.workspace_connections_disconnected.reason_revoked":"Você revogou o acesso ao TryPost na plataforma","mail.workspace_connections_disconnected.reason_changed":"A plataforma mudou os requisitos de autenticação","mail.workspace_connections_disconnected.reconnect_cta":"Por favor, reconecte essas contas para continuar agendando e publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar Contas","notifications.post_ready.title":"Seu post está pronto","notifications.post_ready.body":"A AI terminou. Toque pra revisar e publicar.","notifications.account_disconnected.title":"Conta do :platform desconectada","notifications.account_disconnected.body":":account precisa ser reconectada","notifications.account_token_expired.title":"Conta do :platform precisa ser reconectada","notifications.account_token_expired.body":"Sessão de :account expirou — reconecte pra continuar postando","pagination.previous":"« Anterior","pagination.next":"Próximo »","passwords.reset":"Sua senha foi redefinida.","passwords.sent":"Enviamos o link de redefinição de senha por e-mail.","passwords.throttled":"Por favor, aguarde antes de tentar novamente.","passwords.token":"Este token de redefinição de senha é inválido.","passwords.user":"Não conseguimos encontrar um usuário com esse endereço de e-mail.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos os Posts","posts.new_post":"Novo Post","posts.no_posts":"Nenhum post encontrado","posts.no_search_results":"Nenhum post corresponde à sua busca","posts.try_different_search":"Tente outra palavra-chave ou limpe a busca.","posts.start_creating":"Comece criando seu primeiro post.","posts.filter_by_label":"Filtrar por label","posts.label_search_placeholder":"Buscar labels...","posts.no_labels":"Nenhuma label encontrada.","posts.clear_label_filter":"Limpar filtro de labels","posts.table.post":"Post","posts.table.status":"Status","posts.table.content":"Conteúdo","posts.table.platforms":"Plataformas","posts.table.labels":"Etiquetas","posts.table.scheduled_at":"Data","posts.table.actions":"","posts.manage_posts":"Gerencie todos os seus posts","posts.delete_confirm":"Tem certeza que deseja excluir este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Excluir","posts.actions.duplicate":"Duplicar","posts.actions.copy_id":"Copiar ID","posts.actions.copied":"ID copiado para a área de transferência","posts.form.post_type":"Tipo de Post","posts.form.board":"Pasta","posts.form.select_board":"Selecione uma pasta","posts.form.search_board":"Buscar pasta...","posts.form.no_board_found":"Nenhuma pasta encontrada","posts.form.media":"Mídia","posts.form.min":"Mín","posts.form.uploading":"Enviando...","posts.form.drop_to_upload":"Solte para enviar","posts.form.drag_and_drop":"Arraste e solte ou clique para enviar","posts.form.photos_and_videos":"Fotos e vídeos","posts.form.photos_only":"Apenas fotos","posts.form.videos_only":"Apenas vídeos","posts.form.drag_to_reorder":"Arraste para reordenar","posts.form.caption":"Legenda","posts.form.write_caption":"Escreva sua legenda...","posts.form.content_exceeds_platform":":platform: longo demais por :over caracteres (máx :limit).","posts.form.tiktok.settings":"Configurações do TikTok","posts.form.tiktok.variant_label":"Tipo de publicação","posts.form.tiktok.variant.video":"Vídeo","posts.form.tiktok.variant.photo":"Carrossel de fotos","posts.form.tiktok.posting_to":"Publicando em","posts.form.tiktok.privacy_level":"Quem pode ver este vídeo?","posts.form.tiktok.privacy_placeholder":"Selecione a visibilidade","posts.form.tiktok.privacy.public":"Público para todos","posts.form.tiktok.privacy.friends":"Amigos em comum","posts.form.tiktok.privacy.followers":"Seguidores","posts.form.tiktok.privacy.private":"Apenas eu","posts.form.tiktok.privacy.private_disabled_branded":"Conteúdo de marca não pode ser privado.","posts.form.tiktok.privacy_hint":"As opções disponíveis dependem das configurações da sua conta TikTok.","posts.form.tiktok.auto_add_music":"Adicionar música automaticamente","posts.form.tiktok.auto_add_music_hint":"Disponível apenas para fotos. Adiciona uma música padrão que pode ser alterada depois.","posts.form.tiktok.yes":"Sim","posts.form.tiktok.no":"Não","posts.form.tiktok.allow_users":"Permitir que usuários:","posts.form.tiktok.comments":"Comentem","posts.form.tiktok.duet":"Dueto","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Vídeo feito com IA","posts.form.tiktok.disclose":"Divulgar conteúdo do vídeo","posts.form.tiktok.disclose_hint":"Ative para divulgar que este vídeo promove bens ou serviços em troca de algo de valor. Seu vídeo pode promover você, terceiros ou ambos.","posts.form.tiktok.promotional_organic_title":"Sua foto/vídeo será rotulado como \"Conteúdo Promocional\".","posts.form.tiktok.promotional_paid_title":"Sua foto/vídeo será rotulado como \"Parceria paga\".","posts.form.tiktok.promotional_description":"Isso não poderá ser alterado após a publicação.","posts.form.tiktok.compliance_incomplete":"Você precisa indicar se o conteúdo promove você mesmo, terceiros ou ambos.","posts.form.tiktok.privacy_required":"A visibilidade do TikTok é obrigatória ao publicar.","posts.form.tiktok.branded_cleared_private":"A visibilidade foi limpa porque conteúdo de marca não pode ser privado.","posts.form.tiktok.interaction_disabled_by_creator":"Desativado pelas configurações da sua conta TikTok.","posts.form.tiktok.max_duration_exceeded":"Vídeo tem :duration s mas esta conta só permite vídeos de até :max s.","posts.form.tiktok.processing_hint":"Após publicar, pode levar alguns minutos para o conteúdo ser processado e aparecer no seu perfil TikTok.","posts.form.tiktok.brand_organic":"Sua marca","posts.form.tiktok.brand_organic_hint":"Você está promovendo você mesmo ou sua própria marca. Este vídeo será classificado como Brand Organic.","posts.form.tiktok.brand_content":"Conteúdo patrocinado","posts.form.tiktok.brand_content_hint":"Você está promovendo outra marca ou terceiros. Este vídeo será classificado como Branded Content.","posts.form.tiktok.compliance.agree":"Ao publicar, você concorda com a","posts.form.tiktok.compliance.music_usage":"Confirmação de Uso de Música","posts.form.tiktok.compliance.and":"e","posts.form.tiktok.compliance.branded_policy":"Política de Conteúdo Patrocinado","posts.form.instagram.settings":"Configurações do Instagram","posts.form.instagram.posting_to":"Publicando em","posts.form.instagram.variant_label":"Tipo de publicação","posts.form.instagram.variant.feed":"Post","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Story","posts.form.instagram.aspect_label":"Proporção","posts.form.instagram.aspect.square":"Quadrado (1:1)","posts.form.instagram.aspect.portrait":"Retrato (4:5)","posts.form.instagram.aspect.landscape":"Paisagem (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Configurações do Facebook","posts.form.facebook.posting_to":"Publicando em","posts.form.facebook.variant_label":"Tipo de publicação","posts.form.facebook.variant.post":"Post","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Story","posts.form.linkedin.settings":"Configurações do LinkedIn","posts.form.linkedin.settings_page":"Configurações da Página do LinkedIn","posts.form.linkedin.posting_to":"Publicando em","posts.form.linkedin.variant_label":"Tipo de publicação","posts.form.linkedin.variant.post":"Post","posts.form.linkedin.variant.carousel":"Carrossel","posts.form.pinterest.settings":"Configurações do Pinterest","posts.form.pinterest.posting_to":"Publicando em","posts.form.pinterest.variant_label":"Tipo de pin","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carrossel","posts.form.pinterest.board":"Quadro","posts.form.pinterest.select_board":"Selecione um quadro","posts.form.pinterest.no_boards":"Nenhum quadro do Pinterest encontrado. Crie um na sua conta do Pinterest primeiro.","posts.form.pinterest.search_board":"Pesquisar quadros...","posts.form.pinterest.no_board_found":"Nenhum quadro encontrado.","posts.form.pinterest.board_required":"Selecione um quadro do Pinterest para publicar este post.","posts.form.warnings.no_variant":"Escolha um tipo de publicação para continuar.","posts.form.warnings.requires_media":"Este tipo exige pelo menos uma imagem ou vídeo.","posts.form.warnings.max_files_exceeded":"Este tipo aceita até :max arquivos (você tem :current).","posts.form.warnings.min_files_required":"Este tipo exige pelo menos :min arquivos (você tem :current).","posts.form.warnings.no_video_allowed":"Este tipo não aceita vídeos.","posts.form.warnings.no_image_allowed":"Este tipo aceita apenas vídeos.","posts.form.warnings.gif_not_allowed":"Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.","posts.form.warnings.image_too_large":"A imagem passa do limite de :max (a sua tem :current).","posts.form.warnings.video_too_large":"O vídeo passa do limite de :max (o seu tem :current).","posts.form.warnings.video_too_long":"O vídeo dura :current, mas este tipo permite no máximo :max.","posts.form.warnings.aspect_ratio_too_narrow":"A proporção :current está muito alta (mínimo :min).","posts.form.warnings.aspect_ratio_too_wide":"A proporção :current está muito larga (máximo :max).","posts.status.pending":"Pendente","posts.status.draft":"Rascunho","posts.status.scheduled":"Agendado","posts.status.publishing":"Publicando","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente Publicado","posts.status.failed":"Falhou","posts.descriptions.draft":"Posts aguardando agendamento","posts.descriptions.scheduled":"Posts agendados para publicação","posts.descriptions.published":"Posts já publicados","posts.ai.generate.button_tooltip":"Gerar com IA","posts.ai.generate.title":"Gerar post com IA","posts.ai.generate.description":"Descreva sobre o que o post deve ser. A IA vai usar o contexto da sua marca pra escrever.","posts.ai.generate.prompt_label":"Sobre o que é esse post?","posts.ai.generate.prompt_placeholder":"ex: anunciar nossa nova feature de geração de imagens pra carrosséis","posts.ai.generate.preview_label":"Prévia","posts.ai.generate.start":"Gerar","posts.ai.generate.apply":"Usar este conteúdo","posts.ai.generate.retry":"Tentar de novo","posts.ai.generate.cancel":"Cancelar","posts.ai.review.button_tooltip":"Revisar com IA","posts.ai.review.title":"Revisar post com IA","posts.ai.review.description":"A IA analisa gramática, ortografia e clareza. Aplique as sugestões com as quais você concorda.","posts.ai.review.loading":"Revisando seu texto...","posts.ai.review.no_issues":"Nenhum problema encontrado. Tudo certo.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Sugestão","posts.ai.review.apply":"Aplicar","posts.ai.review.apply_all":"Aplicar todas","posts.ai.review.applied":"Aplicada","posts.ai.review.cancel":"Cancelar","posts.show.title":"Detalhes do Post","posts.show.edit":"Editar","posts.show.back":"Voltar","posts.show.no_content":"Sem legenda","posts.show.platforms":"Plataformas","posts.show.no_platforms":"Nenhuma plataforma selecionada.","posts.show.view_on_platform":"Ver na plataforma","posts.show.published_on":"Publicado em :date","posts.show.scheduled_for":"Agendado para :date","posts.show.draft":"Rascunho","posts.show.status_pending":"Pendente","posts.show.metrics":"Métricas","posts.show.metrics_loading":"Carregando métricas…","posts.show.metrics_unavailable":"Métricas ainda não disponíveis para esta plataforma.","posts.show.metrics_empty":"Nenhuma métrica retornada.","posts.edit.title":"Editar Post","posts.edit.view_title":"Visualizar Post","posts.edit.labels":"Etiqueta","posts.edit.signatures":"Assinaturas","posts.edit.schedule":"Agendar","posts.edit.delete":"Excluir","posts.edit.schedule_for":"Agendar para","posts.edit.unschedule":"Desagendar","posts.edit.saving":"Salvando...","posts.edit.saved":"Salvo","posts.edit.draft":"Rascunho","posts.edit.media":"Mídia","posts.edit.add_media":"Adicionar mídia","posts.edit.caption":"Legenda","posts.edit.caption_placeholder":"Escreva sua legenda...","posts.edit.compose_title":"Crie um post","posts.edit.compose_subtitle":"Componha sua mensagem e adicione mídia","posts.edit.preview_empty.title":"Nenhuma plataforma selecionada","posts.edit.preview_empty.description":"Selecione uma plataforma para publicar e ver o preview.","posts.edit.drop_zone_title":"Adicionar mídia","posts.edit.drop_zone_subtitle":"Arraste arquivos ou clique para selecionar","posts.edit.add":"Adicionar","posts.edit.publish_to":"Publicar em","posts.edit.organize":"Organizar","posts.edit.no_labels":"Nenhuma etiqueta criada ainda","posts.edit.pick_time":"Escolher horário","posts.edit.post_now":"Publicar agora","posts.edit.time":"Horário","posts.edit.cancel":"Cancelar","posts.edit.schedule_date":"Data de agendamento","posts.edit.view_on_platform":"Ver na plataforma","posts.edit.platform_status":"Status da plataforma","posts.edit.compliance_incomplete":"Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.","posts.edit.compliance.requires_media":"Adicione uma imagem ou vídeo para publicar aqui.","posts.edit.compliance.too_many_files":"Apenas :max arquivo(s) permitido(s) para este formato.","posts.edit.compliance.too_few_files":"Adicione pelo menos :min arquivos para este formato.","posts.edit.compliance.no_videos":"Apenas imagens são permitidas neste formato.","posts.edit.compliance.no_images":"Apenas vídeos são permitidos neste formato.","posts.edit.compliance.no_gifs":"GIFs não são suportados aqui.","posts.edit.compliance.video_too_large":"Vídeo excede o limite de tamanho desta plataforma.","posts.edit.compliance.video_too_long":"Vídeo deve ter menos de :seconds segundos neste formato.","posts.edit.compliance.image_too_large":"Imagem excede o limite de tamanho desta plataforma.","posts.edit.compliance.aspect_ratio_invalid":"A proporção da imagem não é suportada por este formato.","posts.edit.compliance.no_content_type":"Escolha um tipo de conteúdo para esta plataforma.","posts.edit.publishing":"Publicando...","posts.edit.publishing_overlay_title":"Seu post está sendo publicado","posts.edit.publishing_overlay_subtitle":"Isso pode levar alguns instantes. Você pode sair desta página sem problemas.","posts.edit.scheduled_overlay_title":"Este post está agendado","posts.edit.scheduled_overlay_subtitle":"Agendado para :date. Desagende para fazer alterações.","posts.edit.unschedule_cta":"Desagendar para editar","posts.edit.tabs.preview":"Pré-visualização","posts.edit.tabs.schedule":"Agendamento","posts.edit.tabs.comments":"Comentários","posts.edit.tabs.comments_empty":"Nenhum comentário ainda.","posts.edit.media_picker.title":"Escolher da galeria","posts.edit.media_picker.search":"Buscar mídia...","posts.edit.media_picker.empty":"Sua galeria ainda está vazia","posts.edit.media_picker.cancel":"Cancelar","posts.edit.media_picker.add":"Adicionar","posts.edit.media_picker.add_count":"Adicionar :count","posts.edit.emoji_picker.search":"Buscar emoji","posts.edit.emoji_picker.empty":"Nenhum emoji encontrado","posts.edit.emoji_picker.recent":"Usados recentemente","posts.edit.emoji_picker.smileys":"Sorrisos e emoções","posts.edit.emoji_picker.people":"Pessoas e corpo","posts.edit.emoji_picker.nature":"Animais e natureza","posts.edit.emoji_picker.food":"Comidas e bebidas","posts.edit.emoji_picker.activities":"Atividades","posts.edit.emoji_picker.travel":"Viagens e lugares","posts.edit.emoji_picker.objects":"Objetos","posts.edit.emoji_picker.symbols":"Símbolos","posts.edit.emoji_picker.flags":"Bandeiras","posts.edit.status.scheduled":"Agendado","posts.edit.status.published":"Publicado","posts.edit.status.publishing":"Publicando...","posts.edit.status.failed":"Falhou","posts.edit.delete_modal.title":"Excluir Post","posts.edit.delete_modal.description":"Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.","posts.edit.delete_modal.action":"Excluir","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"Ativar sincronização?","posts.edit.sync_enable.description":"Todas as plataformas compartilharão o mesmo conteúdo. Qualquer edição personalizada feita em plataformas individuais será substituída pelo conteúdo atual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Ativar sincronização","posts.edit.sync_disable.title":"Desativar sincronização?","posts.edit.sync_disable.description":"Cada plataforma manterá seu conteúdo atual, mas edições futuras serão aplicadas apenas à plataforma que você estiver editando.","posts.edit.sync_disable.customize_note":"Você poderá personalizar o conteúdo para cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desativar sincronização","posts.edit.platforms_dialog.title":"Selecionar Plataformas","posts.edit.platforms_dialog.description":"Escolha em quais plataformas publicar este post.","posts.edit.signatures_modal.search":"Buscar assinaturas...","posts.edit.signatures_modal.no_results":"Nenhuma assinatura encontrada.","posts.edit.validation.select_board":"Selecione uma pasta","posts.edit.validation.images_not_supported":"Imagens não suportadas","posts.edit.validation.videos_not_supported":"Vídeos não suportados","posts.edit.validation.max_images":"Máx :count imagens","posts.edit.validation.requires_media":"Requer mídia","posts.edit.validation.requires_content":"Texto é obrigatório","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform não suporta imagens","posts.edit.validation.supports_up_to_images":":platform suporta até :count imagens","posts.edit.validation.does_not_support_videos":":platform não suporta vídeos","posts.content_types.instagram_feed.label":"Post do Feed","posts.content_types.instagram_feed.description":"Aparece no seu feed e perfil","posts.content_types.instagram_reel.label":"Reels","posts.content_types.instagram_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Desaparece após 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_carousel.label":"Carrossel","posts.content_types.linkedin_carousel.description":"Imagens deslizáveis","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_page_carousel.label":"Carrossel","posts.content_types.linkedin_page_carousel.description":"Imagens deslizáveis","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post padrão na sua página","posts.content_types.facebook_reel.label":"Reels","posts.content_types.facebook_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Desaparece após 24 horas","posts.content_types.tiktok_video.label":"Vídeo","posts.content_types.tiktok_video.description":"Conteúdo de vídeo curto","posts.content_types.tiktok_photo.label":"Carrossel de fotos","posts.content_types.tiktok_photo.description":"Até 35 fotos em um carrossel deslizável","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vídeo vertical de até 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet com texto e mídia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto com mídia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagem padrão","posts.content_types.pinterest_video_pin.label":"Pin de Vídeo","posts.content_types.pinterest_video_pin.description":"Pin de vídeo (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carrossel","posts.content_types.pinterest_carousel.description":"Carrossel multi-imagem (2-5 imagens)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto com imagens opcionais","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto com mídia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página do LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página do Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post agendado com sucesso!","posts.flash.deleted":"Post excluído com sucesso!","posts.flash.duplicated":"Post duplicado como rascunho.","posts.flash.cannot_edit_published":"Posts publicados não podem ser editados.","posts.flash.cannot_delete_published":"Posts publicados não podem ser excluídos.","posts.flash.connect_first":"Conecte pelo menos uma rede social antes de criar um post.","posts.errors.account_disconnected":"Conta social está desconectada","posts.errors.account_inactive":"Conta social está desativada","posts.errors.account_token_expired":"Sessão da conta social expirou — reconecte a conta","posts.delete.title":"Excluir post?","posts.delete.description":"Esta ação não pode ser desfeita. O post e todas as suas mídias serão removidos permanentemente.","posts.delete.confirm":"Sim, excluir","posts.delete.cancel":"Cancelar","posts.create.title":"Criar novo post","posts.create.description":"Escolha como quer começar.","posts.create.scratch_title":"Começar do zero","posts.create.scratch_description":"Abre um post em branco pra você escrever tudo.","posts.create.ai_title":"Gerar com IA","posts.create.ai_description":"Descreva o que quer e a IA gera o conteúdo pra você.","posts.create.ai_configure_description":"Escolha o formato e descreva o post que quer criar.","posts.create.template_title":"Usar um template","posts.create.template_description":"Escolha um dos nossos templates e personalize.","posts.create.coming_soon":"Em breve","posts.create.preview.image_title":"Título da imagem","posts.create.preview.image_body":"Texto da imagem","posts.create.steps.format_title":"Escolha um formato","posts.create.steps.format_description":"Selecione o tipo de post que deseja criar.","posts.create.steps.account_title":"Escolha uma conta","posts.create.steps.account_description":"Selecione a conta social para publicar.","posts.create.steps.media_title":"Opções de mídia","posts.create.steps.media_carousel":"Quantos slides?","posts.create.steps.media_optional":"Incluir imagens?","posts.create.steps.media_optional_label":"Quantas imagens?","posts.create.steps.media_none":"Nenhuma","posts.create.steps.media_count_label":"Número de imagens","posts.create.steps.prompt_title":"Descreva seu post","posts.create.steps.prompt_label":"Sobre o que é este post?","posts.create.steps.prompt_placeholder":"Ex. Anunciar nossa nova função de carrossel para o Instagram","posts.create.steps.preview_error":"Algo deu errado. Por favor, tente novamente.","posts.create.steps.loading_page_title":"Gerando seu post","posts.create.steps.loading_eta":"Tempo estimado: cerca de :minutes.","posts.create.steps.loading_eta_minute_one":"1 minuto","posts.create.steps.loading_eta_minute_other":":count minutos","posts.create.steps.loading_leave_title":"Você pode continuar trabalhando.","posts.create.steps.loading_leave_body":"A gente te avisa assim que o post ficar pronto.","posts.create.steps.loading_leave_cta":"Ir pro calendário","posts.create.steps.loading_create_another_cta":"Criar outro post","posts.create.steps.loading_tip_credits":"Cada imagem AI consome cerca de 15 créditos.","posts.create.steps.loading_tip_edit":"Você poderá editar tudo quando o post ficar pronto.","posts.create.steps.loading_tip_draft":"Posts gerados vão direto pros seus rascunhos.","posts.create.steps.loading_tip_brand":"Ajuste sua marca pra influenciar os próximos posts.","posts.create.steps.loading_tip_carousel":"Carrosséis geram um slide por imagem solicitada.","posts.create.steps.loading_tip_quality":"A qualidade equilibra velocidade e custo.","posts.create.steps.create":"Criar post","posts.create.steps.back":"Voltar","posts.create.steps.next":"Continuar","posts.create.steps.cancel":"Cancelar","posts.create.steps.discard":"Descartar","posts.create.steps.retry":"Tentar novamente","posts.create.steps.no_platforms":"Nenhuma conta conectada","posts.create.steps.connect_first":"Conecte pelo menos uma conta social para usar a geração com IA.","posts.create.steps.format.instagram_feed":"Post no Feed do Instagram","posts.create.steps.format.instagram_carousel":"Carrossel do Instagram","posts.create.steps.format.linkedin_post":"Post no LinkedIn","posts.create.steps.format.linkedin_page_post":"Post em Página do LinkedIn","posts.create.steps.format.x_post":"Post no X","posts.create.steps.format.bluesky_post":"Post no Bluesky","posts.create.steps.format.threads_post":"Post no Threads","posts.create.steps.format.mastodon_post":"Post no Mastodon","posts.create.steps.format.facebook_post":"Post no Facebook","posts.create.steps.format.pinterest_pin":"Pin no Pinterest","posts.create.steps.format.instagram_story":"Story do Instagram","posts.create.steps.format.facebook_story":"Story do Facebook","posts.templates.browser_title":"Escolha um template","posts.templates.browser_description":"Comece com um template pronto e adapte ao seu jeito.","posts.templates.all_platforms":"Todas as plataformas","posts.templates.platform_search_placeholder":"Buscar plataforma…","posts.templates.no_platform_match":"Nenhuma plataforma corresponde.","posts.templates.use_this":"Usar este template","posts.templates.no_templates":"Nenhum template disponível.","posts.templates.applying":"Aplicando template…","posts.templates.search_placeholder":"Buscar templates…","posts.templates.no_search_results":"Nenhum template encontrado","posts.templates.try_different_search":"Tente outra palavra-chave ou limpe a busca.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.category.product_launch":"Lançamento de produto","posts.templates.category.promotion":"Promoção","posts.templates.category.educational":"Educacional","posts.templates.category.behind_the_scenes":"Bastidores","posts.templates.category.testimonial":"Depoimento","posts.templates.category.industry_tip":"Dica do setor","posts.templates.category.event":"Evento","posts.templates.category.engagement":"Engajamento","settings.title":"Configurações","settings.description":"Gerencie seu perfil e configurações da conta","settings.hub.title":"Configurações","settings.hub.description":"Escolha o que você quer gerenciar.","settings.hub.profile.title":"Perfil","settings.hub.profile.description":"Atualize suas informações pessoais, senha e preferências de notificações.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configure seu workspace, marca, membros e chaves de API.","settings.hub.account.title":"Conta","settings.hub.account.description":"Gerencie informações da conta, uso e faturamento.","settings.nav.profile":"Perfil","settings.nav.authentication":"Autenticação","settings.nav.workspace":"Workspace","settings.nav.members":"Membros","settings.nav.notifications":"Notificações","settings.nav.billing":"Faturamento","settings.notifications.title":"Preferências de notificações","settings.notifications.heading":"Notificações por e-mail","settings.notifications.description":"Escolha quais notificações por e-mail deseja receber","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Receber um e-mail quando seu post for publicado com sucesso","settings.notifications.post_failed":"Post falhou","settings.notifications.post_failed_description":"Receber um e-mail quando seu post falhar ao publicar","settings.notifications.account_disconnected":"Conta desconectada","settings.notifications.account_disconnected_description":"Receber um e-mail quando uma conta social for desconectada","settings.notifications.save":"Salvar preferências","settings.profile.title":"Configurações do perfil","settings.profile.photo_heading":"Foto do perfil","settings.profile.photo_description":"Envie uma foto de perfil","settings.profile.heading":"Informações do perfil","settings.profile.description":"Atualize seu nome e endereço de e-mail","settings.profile.avatar":"Avatar","settings.profile.name":"Nome","settings.profile.name_placeholder":"Nome completo","settings.profile.email":"Endereço de e-mail","settings.profile.email_placeholder":"Endereço de e-mail","settings.profile.email_unverified":"Seu endereço de e-mail não foi verificado.","settings.profile.resend_verification":"Clique aqui para reenviar o e-mail de verificação.","settings.profile.verification_sent":"Um novo link de verificação foi enviado para seu endereço de e-mail.","settings.profile.save":"Salvar","settings.authentication.title":"Autenticação","settings.authentication.page_title":"Configurações de autenticação","settings.authentication.sessions.title":"Sessões ativas","settings.authentication.sessions.description":"Se você notar algo suspeito, encerre as sessões em outros dispositivos.","settings.authentication.sessions.unknown_browser":"Navegador desconhecido","settings.authentication.sessions.unknown_ip":"IP desconhecido","settings.authentication.sessions.on":"em","settings.authentication.sessions.active_now":"Ativa agora","settings.authentication.sessions.log_out_others":"Encerrar outras sessões","settings.authentication.sessions.modal_title":"Encerrar outras sessões","settings.authentication.sessions.modal_description_password":"Digite sua senha atual para confirmar o encerramento das outras sessões.","settings.authentication.sessions.modal_description_email":"Digite seu e-mail para confirmar o encerramento das outras sessões.","settings.authentication.sessions.password_placeholder":"Senha atual","settings.authentication.sessions.email_placeholder":"Seu e-mail","settings.authentication.sessions.cancel":"Cancelar","settings.authentication.sessions.submit":"Encerrar outras sessões","settings.authentication.sessions.email_mismatch":"O e-mail não corresponde à sua conta.","settings.authentication.sessions.flash_logged_out":"Outras sessões foram encerradas.","settings.authentication.password.update_title":"Atualizar senha","settings.authentication.password.set_title":"Definir uma senha","settings.authentication.password.update_description":"Use uma senha longa e aleatória para manter sua conta segura.","settings.authentication.password.set_description":"Adicione uma senha para entrar sem precisar de um provedor conectado.","settings.authentication.password.current_password":"Senha atual","settings.authentication.password.new_password":"Nova senha","settings.authentication.password.confirm_password":"Confirmar senha","settings.authentication.password.save":"Salvar senha","settings.authentication.password.set":"Definir senha","settings.authentication.providers.title":"Contas conectadas","settings.authentication.providers.description":"Faça login mais rápido usando esses provedores conectados.","settings.authentication.providers.connected":"Conectada","settings.authentication.providers.not_connected":"Não conectada","settings.authentication.providers.connect":"Conectar","settings.authentication.providers.disconnect":"Desconectar","settings.authentication.providers.flash_disconnected":":provider desconectada com sucesso.","settings.authentication.providers.flash_connected":":provider conectada com sucesso.","settings.authentication.providers.flash_already_linked":"Essa conta do :provider já está vinculada a outro usuário.","settings.authentication.providers.flash_cannot_disconnect":"Você não pode desconectar seu único método de login. Defina uma senha ou conecte outro provedor primeiro.","settings.delete_account.heading":"Excluir conta","settings.delete_account.description":"Exclua sua conta e todos os seus recursos","settings.delete_account.warning":"Atenção","settings.delete_account.warning_message":"Por favor, prossiga com cuidado, isso não pode ser desfeito.","settings.delete_account.button":"Excluir conta","settings.delete_account.modal_title":"Tem certeza que deseja excluir sua conta?","settings.delete_account.modal_description_password":"Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite sua senha para confirmar.","settings.delete_account.modal_description_email":"Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite o seu e-mail :email para confirmar.","settings.delete_account.password":"Senha","settings.delete_account.password_placeholder":"Senha","settings.delete_account.email_placeholder":"Seu e-mail","settings.delete_account.email_mismatch":"O e-mail não corresponde à sua conta.","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Excluir conta","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Marca","settings.workspace.tabs.users":"Membros","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Configurações do workspace","settings.workspace.logo_heading":"Logo do workspace","settings.workspace.logo_description":"Envie um logo para o workspace","settings.workspace.heading":"Nome do workspace","settings.workspace.description":"Atualize o nome do workspace","settings.workspace.members_heading":"Membros","settings.workspace.members_description":"Gerencie membros e convites do workspace","settings.workspace.name":"Nome","settings.workspace.name_placeholder":"Meu Workspace","settings.workspace.save":"Salvar","settings.brand.title":"Marca","settings.brand.description":"Configure a identidade da sua marca para os conteúdos gerados por AI.","settings.brand.name":"Nome do workspace","settings.brand.name_placeholder":"Minha marca","settings.brand.website":"Site","settings.brand.website_placeholder":"https://suamarca.com","settings.brand.brand_description":"Descrição","settings.brand.brand_description_placeholder":"Conte sobre sua marca, o que você faz e quem é seu público...","settings.brand.tone":"Tom de voz","settings.brand.tone_professional":"Profissional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Amigável","settings.brand.tone_bold":"Ousado","settings.brand.tone_inspirational":"Inspirador","settings.brand.tone_humorous":"Bem-humorado","settings.brand.tone_educational":"Educacional","settings.brand.voice_notes":"Notas de voz","settings.brand.voice_notes_placeholder":"Diretrizes adicionais de escrita, palavras a evitar, preferências de estilo...","settings.brand.brand_color":"Cor da marca","settings.brand.background_color":"Cor de fundo","settings.brand.text_color":"Cor do texto","settings.brand.font":"Fonte","settings.brand.image_style":"Estilo das imagens","settings.brand.image_style_description":"Estilo visual aplicado ao gerar imagens de slides e capas para posts com AI.","settings.brand.image_style_cinematic":"Cinematográfico","settings.brand.image_style_illustration":"Ilustração","settings.brand.image_style_isometric_3d":"Isométrico","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Tipográfico","settings.brand.image_style_infographic":"Infográfico","settings.brand.image_style_minimalist":"Minimalista","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Idioma do conteúdo","settings.brand.content_language_description":"Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.","settings.members.title":"Membros","settings.members.heading":"Membros da equipe","settings.members.description":"Gerencie membros e convites deste workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Remover","settings.members.make_admin":"Tornar administrador","settings.members.make_member":"Tornar membro","settings.members.invite.title":"Convidar Membro","settings.members.invite.description":"Envie um convite por e-mail para adicionar colaboradores","settings.members.invite.email":"E-mail","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Função","settings.members.invite.role_placeholder":"Selecione uma função","settings.members.invite.submit":"Enviar Convite","settings.members.pending.title":"Convites Pendentes","settings.members.pending.description":"Convites aguardando aceitação","settings.members.pending.empty":"Nenhum convite pendente","settings.members.list.title":"Membros","settings.members.list.description":"Pessoas com acesso a este workspace","settings.members.list.empty":"Nenhum membro além do proprietário","settings.members.remove_modal.title":"Remover membro","settings.members.remove_modal.description":"Tem certeza que deseja remover este membro do workspace? Ele perderá acesso a todos os recursos do workspace.","settings.members.remove_modal.action":"Remover membro","settings.members.cancel_invite_modal.title":"Cancelar convite","settings.members.cancel_invite_modal.description":"Tem certeza que deseja cancelar este convite?","settings.members.cancel_invite_modal.action":"Cancelar convite","settings.members.roles.owner":"Proprietário","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Membro","settings.members.roles.viewer":"Visualizador","settings.members.flash.invite_sent":"Convite enviado com sucesso!","settings.members.flash.invite_deleted":"Convite excluído.","settings.members.flash.member_removed":"Membro removido com sucesso.","settings.members.flash.role_updated":"Função do membro atualizada.","settings.members.flash.wrong_email":"Este convite é para um endereço de e-mail diferente.","settings.members.flash.already_member":"Você já é membro deste workspace.","settings.members.flash.invite_accepted":"Bem-vindo! Você agora é membro do workspace.","settings.members.flash.invite_declined":"Convite recusado.","settings.account.tabs.account":"Conta","settings.account.tabs.usage":"Uso","settings.account.tabs.billing":"Faturamento","settings.account.title":"Configurações da conta","settings.account.description":"Gerencie o nome da conta e o e-mail de cobrança","settings.account.name":"Nome da conta","settings.account.name_placeholder":"Minha Empresa","settings.account.billing_email":"E-mail de cobrança","settings.account.billing_email_placeholder":"cobranca@empresa.com","settings.account.billing_email_hint":"Este e-mail será usado para faturas e comunicações de cobrança do Stripe.","settings.account.submit":"Salvar","settings.flash.account_updated":"Conta atualizada com sucesso!","settings.flash.profile_updated":"Perfil atualizado com sucesso!","settings.flash.language_updated":"Idioma atualizado com sucesso!","settings.flash.password_updated":"Senha atualizada com sucesso!","settings.flash.workspace_updated":"Configurações atualizadas com sucesso!","settings.flash.photo_updated":"Foto atualizada com sucesso!","settings.flash.photo_deleted":"Foto removida com sucesso!","settings.flash.logo_updated":"Logo enviado com sucesso!","settings.flash.logo_deleted":"Logo removido com sucesso!","settings.flash.notifications_updated":"Preferências de notificações atualizadas!","settings.api_keys.title":"Chaves API","settings.api_keys.page_title":"Chaves API","settings.api_keys.heading":"Chaves API","settings.api_keys.description":"Gerencie chaves API para acesso programático ao seu workspace.","settings.api_keys.create":"Criar chave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Sua nova chave API foi criada. Copie agora — você não poderá vê-la novamente.","settings.api_keys.table.name":"Nome","settings.api_keys.table.key":"Chave","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID da chave API","settings.api_keys.actions.copy_id_success":"ID da chave API copiado","settings.api_keys.actions.delete":"Excluir","settings.api_keys.empty.title":"Nenhuma chave API","settings.api_keys.empty.description":"Crie uma chave API para acessar seu workspace programaticamente.","settings.api_keys.delete_modal.title":"Excluir chave API","settings.api_keys.delete_modal.description":"Tem certeza que deseja excluir esta chave API? Aplicações que a usam perderão acesso imediatamente.","settings.api_keys.delete_modal.action":"Excluir chave API","settings.api_keys.create_dialog.title":"Criar chave API","settings.api_keys.create_dialog.description":"Crie uma nova chave API para acesso programático ao seu workspace.","settings.api_keys.create_dialog.name":"Nome","settings.api_keys.create_dialog.name_placeholder":"ex. Chave API de Produção","settings.api_keys.create_dialog.expires":"Data de expiração (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sem expiração","settings.api_keys.create_dialog.submit":"Criar","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"Chave de API criada com sucesso!","settings.api_keys.flash.deleted":"Chave de API excluída com sucesso!","sidebar.workspaces":"Espaços de trabalho","sidebar.select_workspace":"Selecionar workspace","sidebar.create_workspace":"Criar workspace","sidebar.create_post":"Novo post","sidebar.profile":"Perfil","sidebar.log_out":"Sair","sidebar.workspace.connections":"Conexões","sidebar.workspace.signatures":"Assinaturas","sidebar.workspace.labels":"Etiquetas","sidebar.workspace.assets":"Mídias","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Selecionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Escuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Selecionar","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Suporte","sidebar.analytics":"Analytics","sidebar.settings":"Configurações","sidebar.posts.calendar":"Calendário","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Agendados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Rascunhos","sidebar.notifications":"Notificações","sidebar.mark_all_read":"Marcar tudo como lido","sidebar.mark_as_read":"Marcar como lido","sidebar.archive_all":"Arquivar tudo","sidebar.no_notifications":"Sem notificações","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Enviar feedback","sidebar.support.last_updates":"Últimas Atualizações","sidebar.support.docs":"Documentação","signatures.title":"Assinaturas","signatures.description":"Crie assinaturas reutilizáveis pra anexar rapidamente nos seus posts","signatures.search":"Buscar assinaturas...","signatures.new":"Nova assinatura","signatures.empty_title":"Nenhuma assinatura ainda","signatures.empty_description":"Crie assinaturas pra anexar hashtags, links ou qualquer texto reutilizável nos seus posts","signatures.no_search_results":"Nenhuma assinatura corresponde à busca","signatures.try_different_search":"Tente outra palavra-chave ou limpe a busca.","signatures.table.name":"Nome","signatures.table.content":"Conteúdo","signatures.table.created_at":"Criado em","signatures.actions.edit":"Editar assinatura","signatures.actions.delete":"Excluir assinatura","signatures.create.title":"Criar assinatura","signatures.create.description":"Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).","signatures.create.name":"Nome","signatures.create.name_placeholder":"ex: Marketing, Viagem, Encerramento da marca","signatures.create.content":"Conteúdo","signatures.create.content_placeholder":"#marketing #socialmedia\nSaiba mais: https://suamarca.com","signatures.create.content_hint":"Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.","signatures.create.submit":"Criar assinatura","signatures.create.submitting":"Criando...","signatures.edit.title":"Editar assinatura","signatures.edit.description":"Atualize o nome e o conteúdo desta assinatura.","signatures.edit.name":"Nome","signatures.edit.name_placeholder":"ex: Marketing, Viagem, Encerramento da marca","signatures.edit.content":"Conteúdo","signatures.edit.content_placeholder":"#marketing #socialmedia\nSaiba mais: https://suamarca.com","signatures.edit.content_hint":"Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.","signatures.edit.submit":"Salvar alterações","signatures.edit.submitting":"Salvando...","signatures.delete.title":"Deletar assinatura","signatures.delete.description":"Tem certeza que quer deletar esta assinatura? Esta ação não pode ser desfeita.","signatures.delete.confirm":"Deletar","signatures.delete.cancel":"Cancelar","signatures.flash.created":"Assinatura criada.","signatures.flash.updated":"Assinatura atualizada.","signatures.flash.deleted":"Assinatura deletada.","usage.title":"Uso","usage.section_account":"Conta","usage.section_account_description":"Cotas e limites do seu plano :plan.","usage.section_ai":"Créditos AI","usage.section_ai_description":"Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.","usage.workspaces":"Workspaces","usage.social_accounts":"Contas Sociais","usage.members":"Membros","usage.credits":"Créditos","validation.accepted":"O campo :attribute deve ser aceito.","validation.accepted_if":"O campo :attribute deve ser aceito quando :other for :value.","validation.active_url":"O campo :attribute deve ser uma URL válida.","validation.after":"O campo :attribute deve ser uma data posterior a :date.","validation.after_or_equal":"O campo :attribute deve ser uma data posterior ou igual a :date.","validation.alpha":"O campo :attribute deve conter apenas letras.","validation.alpha_dash":"O campo :attribute deve conter apenas letras, números, hifens e underscores.","validation.alpha_num":"O campo :attribute deve conter apenas letras e números.","validation.any_of":"O campo :attribute é inválido.","validation.array":"O campo :attribute deve ser um array.","validation.ascii":"O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um byte.","validation.before":"O campo :attribute deve ser uma data anterior a :date.","validation.before_or_equal":"O campo :attribute deve ser uma data anterior ou igual a :date.","validation.between.array":"O campo :attribute deve ter entre :min e :max itens.","validation.between.file":"O campo :attribute deve estar entre :min e :max kilobytes.","validation.between.numeric":"O campo :attribute deve estar entre :min e :max.","validation.between.string":"O campo :attribute deve estar entre :min e :max caracteres.","validation.boolean":"O campo :attribute deve ser verdadeiro ou falso.","validation.can":"O campo :attribute contém um valor não autorizado.","validation.confirmed":"A confirmação do campo :attribute não corresponde.","validation.contains":"O campo :attribute está faltando um valor obrigatório.","validation.current_password":"A senha está incorreta.","validation.date":"O campo :attribute deve ser uma data válida.","validation.date_equals":"O campo :attribute deve ser uma data igual a :date.","validation.date_format":"O campo :attribute deve corresponder ao formato :format.","validation.decimal":"O campo :attribute deve ter :decimal casas decimais.","validation.declined":"O campo :attribute deve ser recusado.","validation.declined_if":"O campo :attribute deve ser recusado quando :other for :value.","validation.different":"O campo :attribute e :other devem ser diferentes.","validation.digits":"O campo :attribute deve ter :digits dígitos.","validation.digits_between":"O campo :attribute deve ter entre :min e :max dígitos.","validation.dimensions":"O campo :attribute deve ter dimensões de imagem válidas.","validation.distinct":"O campo :attribute tem um valor duplicado.","validation.doesnt_contain":"O campo :attribute não deve conter nenhum dos seguintes: :values.","validation.doesnt_end_with":"O campo :attribute não deve terminar com nenhum dos seguintes: :values.","validation.doesnt_start_with":"O campo :attribute não deve começar com nenhum dos seguintes: :values.","validation.email":"O campo :attribute deve ser um endereço de e-mail válido.","validation.encoding":"O campo :attribute deve ser codificado em :encoding.","validation.ends_with":"O campo :attribute deve terminar com um dos seguintes: :values.","validation.enum":"O :attribute selecionado é inválido.","validation.exists":"O :attribute selecionado é inválido.","validation.extensions":"O campo :attribute deve ter uma das seguintes extensões: :values.","validation.file":"O campo :attribute deve ser um arquivo.","validation.filled":"O campo :attribute deve ter um valor.","validation.gt.array":"O campo :attribute deve ter mais de :value itens.","validation.gt.file":"O campo :attribute deve ser maior que :value kilobytes.","validation.gt.numeric":"O campo :attribute deve ser maior que :value.","validation.gt.string":"O campo :attribute deve ser maior que :value caracteres.","validation.gte.array":"O campo :attribute deve ter :value itens ou mais.","validation.gte.file":"O campo :attribute deve ser maior ou igual a :value kilobytes.","validation.gte.numeric":"O campo :attribute deve ser maior ou igual a :value.","validation.gte.string":"O campo :attribute deve ser maior ou igual a :value caracteres.","validation.hex_color":"O campo :attribute deve ser uma cor hexadecimal válida.","validation.image":"O campo :attribute deve ser uma imagem.","validation.in":"O :attribute selecionado é inválido.","validation.in_array":"O campo :attribute deve existir em :other.","validation.in_array_keys":"O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.","validation.integer":"O campo :attribute deve ser um inteiro.","validation.ip":"O campo :attribute deve ser um endereço IP válido.","validation.ipv4":"O campo :attribute deve ser um endereço IPv4 válido.","validation.ipv6":"O campo :attribute deve ser um endereço IPv6 válido.","validation.json":"O campo :attribute deve ser uma string JSON válida.","validation.list":"O campo :attribute deve ser uma lista.","validation.lowercase":"O campo :attribute deve estar em minúsculas.","validation.lt.array":"O campo :attribute deve ter menos de :value itens.","validation.lt.file":"O campo :attribute deve ser menor que :value kilobytes.","validation.lt.numeric":"O campo :attribute deve ser menor que :value.","validation.lt.string":"O campo :attribute deve ser menor que :value caracteres.","validation.lte.array":"O campo :attribute deve ter :value itens ou menos.","validation.lte.file":"O campo :attribute deve ser menor ou igual a :value kilobytes.","validation.lte.numeric":"O campo :attribute deve ser menor ou igual a :value.","validation.lte.string":"O campo :attribute deve ser menor ou igual a :value caracteres.","validation.mac_address":"O campo :attribute deve ser um endereço MAC válido.","validation.max.array":"O campo :attribute deve ter no máximo :max itens.","validation.max.file":"O campo :attribute deve ter no máximo :max kilobytes.","validation.max.numeric":"O campo :attribute deve ter no máximo :max.","validation.max.string":"O campo :attribute deve ter no máximo :max caracteres.","validation.max_digits":"O campo :attribute não deve ter mais que :max dígitos.","validation.mimes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.mimetypes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.min.array":"O campo :attribute deve ter pelo menos :min itens.","validation.min.file":"O campo :attribute deve ter pelo menos :min kilobytes.","validation.min.numeric":"O campo :attribute deve ter pelo menos :min.","validation.min.string":"O campo :attribute deve ter pelo menos :min caracteres.","validation.min_digits":"O campo :attribute deve ter pelo menos :min dígitos.","validation.missing":"O campo :attribute deve estar ausente.","validation.missing_if":"O campo :attribute deve estar ausente quando :other for :value.","validation.missing_unless":"O campo :attribute deve estar ausente a menos que :other seja :value.","validation.missing_with":"O campo :attribute deve estar ausente quando :values estiver presente.","validation.missing_with_all":"O campo :attribute deve estar ausente quando :values estiverem presentes.","validation.multiple_of":"O campo :attribute deve ser um múltiplo de :value.","validation.not_in":"O :attribute selecionado é inválido.","validation.not_regex":"O formato do campo :attribute é inválido.","validation.numeric":"O campo :attribute deve ser um número.","validation.password.letters":"O campo :attribute deve conter pelo menos uma letra.","validation.password.mixed":"O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.","validation.password.numbers":"O campo :attribute deve conter pelo menos um número.","validation.password.symbols":"O campo :attribute deve conter pelo menos um símbolo.","validation.password.uncompromised":"O :attribute fornecido apareceu em um vazamento de dados. Por favor, escolha um :attribute diferente.","validation.present":"O campo :attribute deve estar presente.","validation.present_if":"O campo :attribute deve estar presente quando :other for :value.","validation.present_unless":"O campo :attribute deve estar presente a menos que :other seja :value.","validation.present_with":"O campo :attribute deve estar presente quando :values estiver presente.","validation.present_with_all":"O campo :attribute deve estar presente quando :values estiverem presentes.","validation.prohibited":"O campo :attribute é proibido.","validation.prohibited_if":"O campo :attribute é proibido quando :other for :value.","validation.prohibited_if_accepted":"O campo :attribute é proibido quando :other for aceito.","validation.prohibited_if_declined":"O campo :attribute é proibido quando :other for recusado.","validation.prohibited_unless":"O campo :attribute é proibido a menos que :other esteja em :values.","validation.prohibits":"O campo :attribute proíbe :other de estar presente.","validation.regex":"O formato do campo :attribute é inválido.","validation.required":"O campo :attribute é obrigatório.","validation.required_array_keys":"O campo :attribute deve conter entradas para: :values.","validation.required_if":"O campo :attribute é obrigatório quando :other for :value.","validation.required_if_accepted":"O campo :attribute é obrigatório quando :other for aceito.","validation.required_if_declined":"O campo :attribute é obrigatório quando :other for recusado.","validation.required_unless":"O campo :attribute é obrigatório a menos que :other esteja em :values.","validation.required_with":"O campo :attribute é obrigatório quando :values estiver presente.","validation.required_with_all":"O campo :attribute é obrigatório quando :values estiverem presentes.","validation.required_without":"O campo :attribute é obrigatório quando :values não estiver presente.","validation.required_without_all":"O campo :attribute é obrigatório quando nenhum dos :values estiver presente.","validation.same":"O campo :attribute deve ser igual a :other.","validation.size.array":"O campo :attribute deve conter :size itens.","validation.size.file":"O campo :attribute deve ter :size kilobytes.","validation.size.numeric":"O campo :attribute deve ser :size.","validation.size.string":"O campo :attribute deve ter :size caracteres.","validation.starts_with":"O campo :attribute deve começar com um dos seguintes: :values.","validation.string":"O campo :attribute deve ser uma string.","validation.timezone":"O campo :attribute deve ser um fuso horário válido.","validation.unique":"O :attribute já foi utilizado.","validation.uploaded":"O :attribute falhou ao ser enviado.","validation.uppercase":"O campo :attribute deve estar em maiúsculo.","validation.url":"O campo :attribute deve ser uma URL válida.","validation.ulid":"O campo :attribute deve ser um ULID válido.","validation.uuid":"O campo :attribute deve ser um UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Seus workspaces","workspaces.select_description":"Selecione um workspace para continuar","workspaces.current":"Atual","workspaces.connections":":count conexões","workspaces.posts":":count posts","workspaces.create.page_title":"Crie seu workspace","workspaces.create.title":"Configure seu workspace","workspaces.create.description":"Conte um pouco sobre você ou seu projeto. Vamos usar pra personalizar os posts gerados por IA com a sua voz.","workspaces.create.website":"Site","workspaces.create.website_placeholder":"https://suamarca.com","workspaces.create.autofill":"Preencher do site","workspaces.create.autofill_missing_url":"Informe uma URL primeiro.","workspaces.create.autofill_success":"Informações da marca carregadas.","workspaces.create.autofill_error":"Não foi possível preencher automaticamente. Você pode preencher os campos manualmente.","workspaces.create.autofill_errors.unreachable":"Não conseguimos acessar esse site (:reason).","workspaces.create.autofill_errors.http_status":"O site retornou um status inesperado (:status).","workspaces.create.autofill_errors.invalid_scheme":"Apenas URLs http e https são suportadas.","workspaces.create.autofill_errors.missing_host":"A URL está sem um host.","workspaces.create.autofill_errors.unresolvable_host":"Não conseguimos resolver o host (:host).","workspaces.create.autofill_errors.private_network":"URLs apontando para redes privadas não são permitidas.","workspaces.create.logo_captured":"Logo capturada do seu site.","workspaces.create.name":"Nome do workspace","workspaces.create.name_placeholder":"ex. Acme Inc","workspaces.create.brand_description":"Descrição da marca","workspaces.create.brand_description_placeholder":"O que sua marca faz?","workspaces.create.tone":"Tom da marca","workspaces.create.tone_professional":"Profissional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Amigável","workspaces.create.tone_bold":"Ousado","workspaces.create.tone_inspirational":"Inspirador","workspaces.create.tone_humorous":"Bem-humorado","workspaces.create.tone_educational":"Educacional","workspaces.create.content_language":"Idioma do conteúdo","workspaces.create.content_language_description":"Legendas geradas por IA serão escritas neste idioma.","workspaces.create.voice_notes":"Notas de voz (opcional)","workspaces.create.voice_notes_placeholder":"ex. frases curtas e diretas. sem jargão.","workspaces.create.brand_color":"Cor da marca","workspaces.create.background_color":"Cor de fundo","workspaces.create.text_color":"Cor do texto","workspaces.create.submit":"Criar workspace","workspaces.create.success":"Workspace criado. Conecte uma conta social para começar a postar.","workspaces.limit_reached":"Você atingiu o limite de workspaces do seu plano.","workspaces.flash.deleted":"Workspace excluído com sucesso."} \ No newline at end of file +{"accounts.title":"Conexões","accounts.page_title":"Contas Sociais","accounts.description":"Visão geral de todas as suas contas sociais conectadas","accounts.add_social":"Adicionar Rede Social","accounts.add_social_title":"Conectar uma Conta Social","accounts.add_social_description":"Conecte uma conta social ao TryPost para começar a publicar","accounts.connect_cta":"Conectar","accounts.no_accounts":"Nenhuma conta conectada ainda","accounts.no_accounts_description":"Conecte suas redes sociais para começar a agendar e publicar posts","accounts.no_search_results":"Nenhuma conta corresponde à sua busca","accounts.try_different_search":"Tente outra palavra-chave ou limpe a busca.","accounts.search":"Buscar contas...","accounts.added":"Adicionada :date","accounts.limit_reached":"Você atingiu o limite de contas sociais do seu plano.","accounts.not_connected":"Não conectado","accounts.connect":"Conectar","accounts.connection_lost":"Conexão perdida","accounts.reconnect_account":"Reconectar conta","accounts.view_profile":"Ver perfil","accounts.disconnect":"Desconectar","accounts.table.account":"Conta","accounts.table.platform":"Plataforma","accounts.table.status":"Status","accounts.table.last_used":"Último uso","accounts.table.added":"Adicionada","accounts.table.active":"Ativa","accounts.never_used":"Nunca usada","accounts.status.connected":"Conectada","accounts.status.disconnected":"Desconectada","accounts.descriptions.linkedin":"Conecte seu perfil pessoal do LinkedIn","accounts.descriptions.linkedin-page":"Conecte uma página de empresa do LinkedIn","accounts.descriptions.x":"Conecte sua conta do X (Twitter)","accounts.descriptions.tiktok":"Conecte sua conta do TikTok","accounts.descriptions.youtube":"Conecte um canal do YouTube","accounts.descriptions.facebook":"Conecte uma página do Facebook","accounts.descriptions.instagram":"Conecte uma conta profissional do Instagram","accounts.descriptions.instagram-facebook":"Conecte Instagram via página do Facebook","accounts.descriptions.threads":"Conecte sua conta do Threads","accounts.descriptions.pinterest":"Conecte sua conta do Pinterest","accounts.descriptions.bluesky":"Conecte sua conta do Bluesky","accounts.descriptions.mastodon":"Conecte sua conta do Mastodon","accounts.disconnect_modal.title":"Desconectar Conta","accounts.disconnect_modal.description":"Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.","accounts.disconnect_modal.confirm":"Desconectar","accounts.disconnect_modal.cancel":"Cancelar","accounts.bluesky.title":"Conectar Bluesky","accounts.bluesky.description":"Digite suas credenciais para conectar","accounts.bluesky.email":"E-mail","accounts.bluesky.email_placeholder":"seuhandle.bsky.social","accounts.bluesky.app_password":"Senha do App","accounts.bluesky.app_password_placeholder":"xxxx-xxxx-xxxx-xxxx","accounts.bluesky.app_password_hint":"Use uma Senha do App por segurança. Crie uma em bsky.app/settings.","accounts.bluesky.submit":"Conectar Bluesky","accounts.bluesky.submitting":"Conectando...","accounts.mastodon.title":"Conectar Mastodon","accounts.mastodon.description":"Digite a instância do seu Mastodon","accounts.mastodon.instance_url":"URL da Instância","accounts.mastodon.instance_placeholder":"https://mastodon.social","accounts.mastodon.instance_hint":"Digite a URL da sua instância Mastodon (ex: mastodon.social, techhub.social)","accounts.mastodon.submit":"Continuar com Mastodon","accounts.mastodon.submitting":"Conectando...","accounts.facebook.title":"Selecionar Página do Facebook","accounts.facebook.description":"Escolha qual página você deseja conectar","accounts.facebook.no_pages":"Nenhuma página encontrada","accounts.facebook.no_pages_description":"Você não é administrador de nenhuma página do Facebook.","accounts.facebook.page_label":"Página do Facebook","accounts.instagram_facebook.title":"Selecionar Conta do Instagram","accounts.instagram_facebook.description":"Escolha qual conta do Instagram você deseja conectar","accounts.instagram_facebook.no_pages":"Nenhuma conta do Instagram encontrada","accounts.instagram_facebook.no_pages_description":"Nenhuma Página do Facebook com conta Instagram Business vinculada foi encontrada.","accounts.linkedin.title":"Selecionar Página do LinkedIn","accounts.linkedin.description":"Escolha qual página você deseja conectar","accounts.linkedin.no_pages":"Nenhuma página encontrada","accounts.linkedin.no_pages_description":"Você não é administrador de nenhuma página do LinkedIn.","accounts.linkedin.page_label":"Página do LinkedIn","accounts.flash.disconnected":"Conta desconectada com sucesso!","accounts.flash.connected":"Conta conectada com sucesso!","accounts.flash.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.flash.workspace_not_found":"Workspace não encontrado.","accounts.flash.activated":"Conta ativada!","accounts.flash.deactivated":"Conta desativada!","accounts.flash.already_connected":"Esta plataforma já está conectada.","accounts.flash.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","accounts.popup_callback.title_success":"Conectado","accounts.popup_callback.title_error":"Erro","accounts.popup_callback.closing":"Esta janela será fechada automaticamente...","accounts.popup_callback.close_now":"Você pode fechar esta janela agora.","accounts.popup_callback.connected":"Conta conectada!","accounts.popup_callback.reconnected":"Conta reconectada!","accounts.popup_callback.error_connecting":"Erro ao conectar conta. Por favor, tente novamente.","accounts.popup_callback.error_connecting_page":"Erro ao conectar página. Por favor, tente novamente.","accounts.popup_callback.error_connecting_channel":"Erro ao conectar canal. Por favor, tente novamente.","accounts.popup_callback.session_expired":"Sessão expirada. Por favor, tente novamente.","accounts.popup_callback.workspace_not_found":"Workspace não encontrado.","accounts.popup_callback.invalid_state":"Estado inválido. Por favor, tente novamente.","accounts.popup_callback.failed_to_authenticate":"Falha na autenticação.","accounts.popup_callback.failed_to_get_profile":"Falha ao obter perfil.","accounts.popup_callback.page_not_found":"Página não encontrada.","accounts.popup_callback.channel_not_found":"Canal não encontrado.","accounts.popup_callback.no_facebook_pages":"Nenhuma página do Facebook encontrada. Você precisa ser administrador de pelo menos uma página.","accounts.popup_callback.no_facebook_instagram_pages":"Nenhuma página do Facebook com conta do Instagram vinculada foi encontrada.","accounts.popup_callback.no_youtube_channels":"Nenhum canal do YouTube encontrado. Por favor, crie um canal primeiro.","accounts.popup_callback.not_linkedin_admin":"Você não é administrador de nenhuma página do LinkedIn.","analytics.no_accounts":"Nenhuma conta conectada com analytics.","analytics.no_accounts_match":"Nenhuma conta corresponde.","analytics.search_account":"Buscar conta…","analytics.select_account":"Selecione uma conta para ver analytics.","analytics.no_data":"Nenhum dado de analytics disponível.","analytics.metrics.avg_view_duration":"Duração Média (s)","analytics.metrics.avg_view_percentage":"Visualização Média","analytics.metrics.bookmarks":"Salvos","analytics.metrics.clicks":"Cliques","analytics.metrics.comments":"Comentários","analytics.metrics.engagement":"Engajamento","analytics.metrics.favourites":"Favoritos","analytics.metrics.followers":"Seguidores","analytics.metrics.following":"Seguindo","analytics.metrics.impressions":"Impressões","analytics.metrics.interactions":"Interações","analytics.metrics.likes":"Curtidas","analytics.metrics.minutes_watched":"Minutos Assistidos","analytics.metrics.organic_followers":"Seguidores Orgânicos","analytics.metrics.outbound_clicks":"Cliques Externos","analytics.metrics.page_followers":"Seguidores da Página","analytics.metrics.page_reach":"Alcance da Página","analytics.metrics.page_views":"Visualizações da Página","analytics.metrics.paid_followers":"Seguidores Pagos","analytics.metrics.pin_click_rate":"Taxa de Clique em Pins","analytics.metrics.pin_clicks":"Cliques em Pins","analytics.metrics.posts_engagement":"Engajamento dos Posts","analytics.metrics.posts_reach":"Alcance dos Posts","analytics.metrics.quotes":"Citações","analytics.metrics.reach":"Alcance","analytics.metrics.reblogs":"Reblogs","analytics.metrics.recent_comments":"Comentários Recentes","analytics.metrics.recent_likes":"Curtidas Recentes","analytics.metrics.recent_shares":"Compartilhamentos Recentes","analytics.metrics.replies":"Respostas","analytics.metrics.reposts":"Reposts","analytics.metrics.retweets":"Retweets","analytics.metrics.saves":"Salvos","analytics.metrics.shares":"Compartilhamentos","analytics.metrics.subscribers_gained":"Inscritos Ganhos","analytics.metrics.subscribers_lost":"Inscritos Perdidos","analytics.metrics.total_likes":"Curtidas Totais","analytics.metrics.video_views":"Visualizações de Vídeo","analytics.metrics.videos":"Vídeos","analytics.metrics.views":"Visualizações","assets.title":"Mídias","assets.tabs.my_uploads":"Meus uploads","assets.tabs.stock_photos":"Fotos gratuitas","assets.tabs.gifs":"GIFs","assets.upload.drag_drop":"Arraste e solte seus arquivos aqui ou clique para selecionar","assets.upload.formats":"JPEG, PNG, GIF, WebP, MP4","assets.upload.uploading":"Enviando...","assets.empty.title":"Nenhuma mídia ainda","assets.empty.description":"Envie imagens e vídeos para criar sua biblioteca de mídia.","assets.save_to_assets":"Salvar na biblioteca","assets.saved":"Salvo na sua biblioteca!","assets.create_post":"Criar post","assets.add_to_post":"Adicionar ao post","assets.search_placeholder":"Buscar mídia...","assets.delete.title":"Excluir mídia","assets.delete.description":"Tem certeza que deseja excluir esta mídia? Esta ação não pode ser desfeita.","assets.delete.confirm":"Excluir","assets.delete.cancel":"Cancelar","assets.unsplash.search_placeholder":"Buscar fotos gratuitas...","assets.unsplash.no_results":"Nenhuma foto encontrada","assets.unsplash.no_results_description":"Tente outro termo de busca.","assets.unsplash.trending":"Em alta no Unsplash","assets.unsplash.start_searching":"Busque fotos gratuitas do Unsplash","assets.giphy.trending":"Em alta no Giphy","assets.giphy.search_placeholder":"Buscar GIFs...","assets.giphy.no_results":"Nenhum GIF encontrado","assets.giphy.no_results_description":"Tente outro termo de busca.","assets.giphy.powered_by":"Powered by GIPHY","auth.failed":"Essas credenciais não correspondem aos nossos registros.","auth.password":"A senha fornecida está incorreta.","auth.throttle":"Muitas tentativas de login. Por favor, tente novamente em :seconds segundos.","auth.flash.welcome":"Bem-vindo ao TryPost!","auth.flash.welcome_trial":"Bem-vindo ao TryPost! Seu período de teste começou.","auth.legal":"Ao continuar, você concorda com nossos Termos de Serviço e Política de Privacidade.","auth.slides.calendar.title":"Calendário Visual","auth.slides.calendar.description":"Planeje e agende seu conteúdo com um calendário intuitivo de arrastar e soltar em todas as suas contas sociais.","auth.slides.scheduling.title":"Agendamento Inteligente","auth.slides.scheduling.description":"Agende posts no LinkedIn, X, Instagram, TikTok, YouTube e mais — tudo em um só lugar.","auth.slides.media.title":"Mídia Rica","auth.slides.media.description":"Publique imagens, carrosséis, stories e reels. Cada plataforma recebe o formato correto automaticamente.","auth.slides.video.title":"Publicação de Vídeo","auth.slides.video.description":"Envie vídeos uma vez e publique no TikTok, YouTube Shorts, Instagram Reels e Facebook Reels.","auth.slides.team.title":"Workspaces em Equipe","auth.slides.team.description":"Convide sua equipe, atribua funções e gerencie múltiplas marcas em workspaces separados.","auth.slides.signatures.title":"Assinaturas","auth.slides.signatures.description":"Salve assinaturas reutilizáveis (hashtags, links, encerramentos) e anexe nos posts com um clique.","auth.or_continue_with":"Ou continue com","auth.google_login":"Entrar com Google","auth.google_signup":"Cadastrar com Google","auth.github_login":"Entrar com GitHub","auth.github_signup":"Cadastrar com GitHub","auth.github_email_unavailable":"Não foi possível obter seu e-mail do GitHub. Torne seu e-mail público ou conceda a permissão de e-mail e tente novamente.","auth.signup_success.page_title":"Bem-vindo","auth.signup_success.title":"Configurando sua conta","auth.signup_success.description":"Isso geralmente leva apenas alguns segundos...","auth.login.title":"Entrar na sua conta","auth.login.description":"Digite seu email e senha abaixo para entrar","auth.login.page_title":"Entrar","auth.login.email":"Endereço de email","auth.login.password":"Senha","auth.login.forgot_password":"Esqueceu a senha?","auth.login.remember_me":"Lembrar de mim","auth.login.submit":"Entrar","auth.login.no_account":"Não tem uma conta?","auth.login.sign_up":"Cadastre-se","auth.register.title":"Criar uma conta","auth.register.description":"Digite seus dados abaixo para criar sua conta","auth.register.page_title":"Cadastro","auth.register.name":"Nome","auth.register.name_placeholder":"Nome completo","auth.register.email":"Endereço de email","auth.register.password":"Senha","auth.register.show_password":"Mostrar senha","auth.register.hide_password":"Esconder senha","auth.register.submit":"Criar conta","auth.register.has_account":"Já tem uma conta?","auth.register.log_in":"Entrar","auth.forgot_password.title":"Esqueceu a senha","auth.forgot_password.description":"Digite seu email para receber um link de redefinição de senha","auth.forgot_password.page_title":"Esqueceu a senha","auth.forgot_password.email":"Endereço de email","auth.forgot_password.submit":"Enviar link de redefinição","auth.forgot_password.return_to":"Ou, volte para","auth.forgot_password.log_in":"entrar","auth.reset_password.title":"Redefinir senha","auth.reset_password.description":"Por favor, digite sua nova senha abaixo","auth.reset_password.page_title":"Redefinir senha","auth.reset_password.email":"Email","auth.reset_password.password":"Senha","auth.reset_password.confirm_password":"Confirmar Senha","auth.reset_password.confirm_placeholder":"Confirmar senha","auth.reset_password.submit":"Redefinir senha","auth.verify_email.title":"Verificar email","auth.verify_email.description":"Por favor, verifique seu endereço de email clicando no link que acabamos de enviar.","auth.verify_email.page_title":"Verificação de email","auth.verify_email.link_sent":"Um novo link de verificação foi enviado para o endereço de email que você forneceu durante o cadastro.","auth.verify_email.resend":"Reenviar email de verificação","auth.verify_email.log_out":"Sair","auth.accept_invite.page_title":"Aceitar Convite","auth.accept_invite.title":"Você foi convidado!","auth.accept_invite.description":"Você foi convidado para participar do workspace :workspace.","auth.accept_invite.workspace":"Workspace","auth.accept_invite.your_role":"Seu cargo","auth.accept_invite.email":"Email","auth.accept_invite.accept":"Aceitar Convite","auth.accept_invite.decline":"Recusar Convite","auth.accept_invite.login_prompt":"Entre ou crie uma conta para aceitar este convite.","auth.accept_invite.log_in":"Entrar","auth.accept_invite.create_account":"Criar Conta","billing.title":"Faturamento","billing.upgrade_dialog.title":"Faça upgrade do seu plano","billing.upgrade_dialog.description":"Escolha um plano que se encaixe nas suas necessidades.","billing.upgrade_dialog.current_plan":"Plano atual","billing.upgrade_dialog.current_short":"Atual","billing.upgrade_dialog.current_badge":"Atual","billing.upgrade_dialog.subscribe":"Assinar","billing.upgrade_dialog.switch":"Mudar para este plano","billing.upgrade_dialog.switch_short":"Mudar","billing.upgrade_dialog.switch_to_yearly":"Mudar para anual","billing.upgrade_dialog.switch_to_monthly":"Mudar para mensal","billing.upgrade_dialog.unavailable":"Indisponível","billing.upgrade_dialog.reasons.workspace_limit":"Você atingiu o limite de workspaces do seu plano. Faça upgrade pra criar mais.","billing.upgrade_dialog.reasons.social_account_limit":"Você atingiu o limite de contas sociais do seu plano. Faça upgrade pra conectar mais.","billing.upgrade_dialog.reasons.member_limit":"Você atingiu o limite de membros do seu plano. Faça upgrade pra convidar mais pessoas.","billing.subscribe.page_title":"Escolha seu plano","billing.subscribe.eyebrow":"Preços","billing.subscribe.title":"Escolha o plano ideal pra você","billing.subscribe.description":"Escolha o plano que combina com você. Cobrança mensal ou anual.","billing.subscribe.monthly":"Mensal","billing.subscribe.yearly":"Anual","billing.subscribe.per_month":"mensal","billing.subscribe.per_year":"anual","billing.subscribe.billed_monthly":"Cobrança mensal","billing.subscribe.billed_yearly":"Cobrança anual","billing.subscribe.features_included":"O que está incluído:","billing.subscribe.everything_in":"Tudo do :plan, mais:","billing.subscribe.save_months":"2 meses grátis","billing.subscribe.popular":"Mais popular","billing.subscribe.subscribe_cta":"Assinar","billing.subscribe.prices.starter.monthly":"R$ 95","billing.subscribe.prices.starter.yearly_per_month":"R$ 79","billing.subscribe.prices.starter.yearly":"R$ 950","billing.subscribe.prices.plus.monthly":"R$ 145","billing.subscribe.prices.plus.yearly_per_month":"R$ 121","billing.subscribe.prices.plus.yearly":"R$ 1450","billing.subscribe.prices.pro.monthly":"R$ 245","billing.subscribe.prices.pro.yearly_per_month":"R$ 204","billing.subscribe.prices.pro.yearly":"R$ 2450","billing.subscribe.prices.max.monthly":"R$ 495","billing.subscribe.prices.max.yearly_per_month":"R$ 413","billing.subscribe.prices.max.yearly":"R$ 4950","billing.subscribe.features.social_accounts":":count contas sociais","billing.subscribe.features.workspaces":":count workspaces","billing.subscribe.features.members":":count membros da equipe","billing.subscribe.features.credits":":count créditos IA/mês","billing.subscribe.credit_tooltips.starter":"Em média 150 posts de tamanho médio + 5 imagens de IA por mês.","billing.subscribe.credit_tooltips.plus":"Em média 300 posts de tamanho médio + 10 imagens de IA por mês.","billing.subscribe.credit_tooltips.pro":"Em média 700 posts de tamanho médio + 30 imagens de IA por mês.","billing.subscribe.credit_tooltips.max":"Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.","billing.plan.title":"Plano","billing.plan.description":"Gerencie seu plano de assinatura.","billing.plan.change":"Mudar plano","billing.plan.label":"Plano","billing.plan.price":"Preço","billing.plan.month":"mês","billing.plan.trial":"Trial","billing.plan.active":"Ativo","billing.plan.past_due":"Vencido","billing.plan.cancelling":"Cancelando","billing.plan.trial_ends":"Teste termina em","billing.subscription.title":"Assinatura","billing.subscription.description":"Gerencie seu método de pagamento, dados de cobrança e assinatura.","billing.subscription.payment_method":"Método de pagamento","billing.subscription.no_payment_method":"Nenhum método de pagamento cadastrado.","billing.subscription.expires_on":"Expira em :month/:year","billing.subscription.manage_label":"Assinatura","billing.subscription.manage_stripe":"Gerenciar no Stripe","billing.invoices.title":"Faturas","billing.invoices.description":"Baixe suas faturas anteriores.","billing.invoices.empty":"Nenhuma fatura encontrada","billing.invoices.paid":"Pago","billing.flash.plan_changed":"Você está agora no plano :plan.","billing.flash.cannot_manage":"Apenas o owner da conta pode gerenciar a cobrança.","billing.flash.cannot_downgrade.workspaces":"Não é possível mudar para :plan: você tem :count workspaces mas o plano só permite :limit.","billing.flash.cannot_downgrade.social_accounts":"Não é possível mudar para :plan: você tem :count contas sociais mas o plano só permite :limit.","billing.flash.cannot_downgrade.members":"Não é possível mudar para :plan: você tem :count membros (incluindo convites) mas o plano só permite :limit.","billing.flash.credits_exhausted":"Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.","billing.processing.page_title":"Processando...","billing.processing.title":"Processando sua assinatura","billing.processing.description":"Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.","billing.processing.success_title":"Tudo pronto!","billing.processing.success_description":"Sua assinatura está ativa. Redirecionando para seus workspaces...","billing.processing.cancelled_title":"Pagamento cancelado","billing.processing.cancelled_description":"Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.","billing.processing.retry":"Tentar novamente","brands.new_brand":"Nova Marca","brands.no_brands_yet":"Nenhuma marca ainda","brands.no_brands_description":"Crie marcas para organizar suas contas de redes sociais por cliente ou projeto","brands.accounts_count":":count contas","brands.create.title":"Criar Marca","brands.create.description":"Dê um nome à sua marca para agrupar contas de redes sociais","brands.create.name":"Nome da Marca","brands.create.name_placeholder":"ex. Acme Corp, Pessoal","brands.create.submit":"Criar Marca","brands.create.submitting":"Criando...","brands.edit.title":"Editar Marca","brands.edit.description":"Atualize o nome desta marca","brands.edit.name":"Nome da Marca","brands.edit.name_placeholder":"ex. Acme Corp, Pessoal","brands.edit.submit":"Salvar Alterações","brands.edit.submitting":"Salvando...","brands.delete.title":"Excluir Marca","brands.delete.description":"Tem certeza de que deseja excluir esta marca? As contas de redes sociais serão desvinculadas mas não excluídas.","brands.delete.confirm":"Excluir","brands.delete.cancel":"Cancelar","brands.flash.created":"Marca criada com sucesso!","brands.flash.updated":"Marca atualizada com sucesso!","brands.flash.deleted":"Marca excluída com sucesso!","calendar.title":"Calendário","calendar.today":"Hoje","calendar.day":"Dia","calendar.week":"Semana","calendar.month":"Mês","calendar.new_post":"Novo Post","calendar.no_content":"Sem conteúdo","calendar.more":"+:count mais","comments.placeholder":"Escreva um comentário...","comments.reply_placeholder":"Escreva uma resposta...","comments.reply":"Responder","comments.edit":"Editar","comments.delete":"Excluir","comments.edited":"editado","comments.save":"Salvar","comments.cancel":"Cancelar","comments.send":"Enviar","comments.replying_to":"Respondendo a :name","comments.empty":"Nenhum comentário ainda. Comece a conversa.","comments.load_more":"Carregar comentários antigos","comments.today":"Hoje","comments.yesterday":"Ontem","common.confirm_modal.cannot_be_undone":"Esta ação não pode ser desfeita.","common.confirm_modal.type":"Digite","common.confirm_modal.to_confirm":"para confirmar.","common.confirm_modal.copy_to_clipboard":"Copiar para a área de transferência","common.confirm_modal.delete_keyword":"deletar","common.photo_upload.upload":"Enviar","common.photo_upload.uploading":"Enviando...","common.photo_upload.remove":"Remover foto","common.photo_upload.hint":"Recomendado: imagem quadrada, máximo 2 MB.","common.timezone.select":"Selecionar fuso horário","common.timezone.search":"Buscar fuso horário...","common.timezone.empty":"Fuso horário não encontrado","common.date_picker.select":"Selecionar data","common.date_range_picker.placeholder":"Escolha um período","common.date_range_picker.today":"Hoje","common.date_range_picker.yesterday":"Ontem","common.date_range_picker.last_7_days":"Últimos 7 dias","common.date_range_picker.last_30_days":"Últimos 30 dias","common.date_range_picker.last_3_months":"Últimos 3 meses","common.date_range_picker.last_6_months":"Últimos 6 meses","common.date_range_picker.last_12_months":"Últimos 12 meses","common.date_range_picker.this_month":"Este mês","common.date_range_picker.last_month":"Mês passado","common.date_range_picker.year_to_date":"Desde o início do ano","common.date_range_picker.last_year":"Ano passado","common.cancel":"Cancelar","common.clear":"Limpar","common.close":"Fechar","common.loading_more":"Carregando mais...","labels.title":"Etiquetas","labels.description":"Crie etiquetas para organizar e categorizar seus posts","labels.search":"Buscar etiquetas...","labels.new_label":"Nova Etiqueta","labels.no_labels_yet":"Nenhuma etiqueta ainda","labels.no_search_results":"Nenhuma etiqueta corresponde à sua busca","labels.try_different_search":"Tente outra palavra-chave ou limpe a busca.","labels.create_first_label":"Crie sua primeira etiqueta","labels.table.name":"Nome","labels.table.created_at":"Criado","labels.actions.edit":"Editar etiqueta","labels.actions.delete":"Excluir etiqueta","labels.create.title":"Criar Etiqueta","labels.create.description":"Dê um nome e escolha uma cor para sua etiqueta","labels.create.name":"Nome","labels.create.name_placeholder":"Digite o nome da etiqueta...","labels.create.color":"Cor","labels.create.submit":"Criar Etiqueta","labels.create.submitting":"Criando...","labels.edit.title":"Editar Etiqueta","labels.edit.description":"Atualize o nome e a cor desta etiqueta","labels.edit.name":"Nome","labels.edit.name_placeholder":"Digite o nome da etiqueta...","labels.edit.color":"Cor","labels.edit.submit":"Salvar Alterações","labels.edit.submitting":"Salvando...","labels.delete.title":"Excluir Etiqueta","labels.delete.description":"Tem certeza que deseja excluir esta etiqueta? Esta ação não pode ser desfeita.","labels.delete.confirm":"Excluir","labels.delete.cancel":"Cancelar","labels.flash.created":"Etiqueta criada com sucesso!","labels.flash.updated":"Etiqueta atualizada com sucesso!","labels.flash.deleted":"Etiqueta excluída com sucesso!","mail.mentioned.subject":":name mencionou você no TryPost","mail.mentioned.title":":name mencionou você","mail.mentioned.intro":":name mencionou você num comentário.","mail.mentioned.cta":"Ver comentário","mail.workspace_connections_disconnected.subject":"{1} :count conta precisa ser reconectada em :workspace|[2,*] :count contas precisam ser reconectadas em :workspace","mail.workspace_connections_disconnected.title":"Contas Precisam ser Reconectadas","mail.workspace_connections_disconnected.intro":"As seguintes contas de redes sociais no seu workspace :workspace foram desconectadas e precisam ser reconectadas:","mail.workspace_connections_disconnected.reasons_title":"Isso pode ter acontecido porque:","mail.workspace_connections_disconnected.reason_expired":"Os tokens de acesso expiraram","mail.workspace_connections_disconnected.reason_revoked":"Você revogou o acesso ao TryPost na plataforma","mail.workspace_connections_disconnected.reason_changed":"A plataforma mudou os requisitos de autenticação","mail.workspace_connections_disconnected.reconnect_cta":"Por favor, reconecte essas contas para continuar agendando e publicando posts.","mail.workspace_connections_disconnected.button":"Reconectar Contas","notifications.post_ready.title":"Seu post está pronto","notifications.post_ready.body":"A AI terminou. Toque pra revisar e publicar.","notifications.account_disconnected.title":"Conta do :platform desconectada","notifications.account_disconnected.body":":account precisa ser reconectada","notifications.account_token_expired.title":"Conta do :platform precisa ser reconectada","notifications.account_token_expired.body":"Sessão de :account expirou — reconecte pra continuar postando","pagination.previous":"« Anterior","pagination.next":"Próximo »","passwords.reset":"Sua senha foi redefinida.","passwords.sent":"Enviamos o link de redefinição de senha por e-mail.","passwords.throttled":"Por favor, aguarde antes de tentar novamente.","passwords.token":"Este token de redefinição de senha é inválido.","passwords.user":"Não conseguimos encontrar um usuário com esse endereço de e-mail.","posts.title":"Posts","posts.search":"Buscar posts...","posts.all_posts":"Todos os Posts","posts.new_post":"Novo Post","posts.no_posts":"Nenhum post encontrado","posts.no_search_results":"Nenhum post corresponde à sua busca","posts.try_different_search":"Tente outra palavra-chave ou limpe a busca.","posts.start_creating":"Comece criando seu primeiro post.","posts.filter_by_label":"Filtrar por label","posts.label_search_placeholder":"Buscar labels...","posts.no_labels":"Nenhuma label encontrada.","posts.clear_label_filter":"Limpar filtro de labels","posts.table.post":"Post","posts.table.status":"Status","posts.table.content":"Conteúdo","posts.table.platforms":"Plataformas","posts.table.labels":"Etiquetas","posts.table.scheduled_at":"Data","posts.table.actions":"","posts.manage_posts":"Gerencie todos os seus posts","posts.delete_confirm":"Tem certeza que deseja excluir este post?","posts.by":"por","posts.actions.view":"Ver post","posts.actions.delete":"Excluir","posts.actions.duplicate":"Duplicar","posts.actions.copy_id":"Copiar ID","posts.actions.copied":"ID copiado para a área de transferência","posts.form.post_type":"Tipo de Post","posts.form.board":"Pasta","posts.form.select_board":"Selecione uma pasta","posts.form.search_board":"Buscar pasta...","posts.form.no_board_found":"Nenhuma pasta encontrada","posts.form.media":"Mídia","posts.form.min":"Mín","posts.form.uploading":"Enviando...","posts.form.drop_to_upload":"Solte para enviar","posts.form.drag_and_drop":"Arraste e solte ou clique para enviar","posts.form.photos_and_videos":"Fotos e vídeos","posts.form.photos_only":"Apenas fotos","posts.form.videos_only":"Apenas vídeos","posts.form.drag_to_reorder":"Arraste para reordenar","posts.form.caption":"Legenda","posts.form.write_caption":"Escreva sua legenda...","posts.form.content_exceeds_platform":":platform: longo demais por :over caracteres (máx :limit).","posts.form.tiktok.settings":"Configurações do TikTok","posts.form.tiktok.variant_label":"Tipo de publicação","posts.form.tiktok.variant.video":"Vídeo","posts.form.tiktok.variant.photo":"Carrossel de fotos","posts.form.tiktok.posting_to":"Publicando em","posts.form.tiktok.privacy_level":"Quem pode ver este vídeo?","posts.form.tiktok.privacy_placeholder":"Selecione a visibilidade","posts.form.tiktok.privacy.public":"Público para todos","posts.form.tiktok.privacy.friends":"Amigos em comum","posts.form.tiktok.privacy.followers":"Seguidores","posts.form.tiktok.privacy.private":"Apenas eu","posts.form.tiktok.privacy.private_disabled_branded":"Conteúdo de marca não pode ser privado.","posts.form.tiktok.privacy_hint":"As opções disponíveis dependem das configurações da sua conta TikTok.","posts.form.tiktok.auto_add_music":"Adicionar música automaticamente","posts.form.tiktok.auto_add_music_hint":"Disponível apenas para fotos. Adiciona uma música padrão que pode ser alterada depois.","posts.form.tiktok.yes":"Sim","posts.form.tiktok.no":"Não","posts.form.tiktok.allow_users":"Permitir que usuários:","posts.form.tiktok.comments":"Comentem","posts.form.tiktok.duet":"Dueto","posts.form.tiktok.stitch":"Stitch","posts.form.tiktok.is_aigc":"Vídeo feito com IA","posts.form.tiktok.disclose":"Divulgar conteúdo do vídeo","posts.form.tiktok.disclose_hint":"Ative para divulgar que este vídeo promove bens ou serviços em troca de algo de valor. Seu vídeo pode promover você, terceiros ou ambos.","posts.form.tiktok.promotional_organic_title":"Sua foto/vídeo será rotulado como \"Conteúdo Promocional\".","posts.form.tiktok.promotional_paid_title":"Sua foto/vídeo será rotulado como \"Parceria paga\".","posts.form.tiktok.promotional_description":"Isso não poderá ser alterado após a publicação.","posts.form.tiktok.compliance_incomplete":"Você precisa indicar se o conteúdo promove você mesmo, terceiros ou ambos.","posts.form.tiktok.privacy_required":"A visibilidade do TikTok é obrigatória ao publicar.","posts.form.tiktok.branded_cleared_private":"A visibilidade foi limpa porque conteúdo de marca não pode ser privado.","posts.form.tiktok.interaction_disabled_by_creator":"Desativado pelas configurações da sua conta TikTok.","posts.form.tiktok.max_duration_exceeded":"Vídeo tem :duration s mas esta conta só permite vídeos de até :max s.","posts.form.tiktok.processing_hint":"Após publicar, pode levar alguns minutos para o conteúdo ser processado e aparecer no seu perfil TikTok.","posts.form.tiktok.brand_organic":"Sua marca","posts.form.tiktok.brand_organic_hint":"Você está promovendo você mesmo ou sua própria marca. Este vídeo será classificado como Brand Organic.","posts.form.tiktok.brand_content":"Conteúdo patrocinado","posts.form.tiktok.brand_content_hint":"Você está promovendo outra marca ou terceiros. Este vídeo será classificado como Branded Content.","posts.form.tiktok.compliance.agree":"Ao publicar, você concorda com a","posts.form.tiktok.compliance.music_usage":"Confirmação de Uso de Música","posts.form.tiktok.compliance.and":"e","posts.form.tiktok.compliance.branded_policy":"Política de Conteúdo Patrocinado","posts.form.instagram.settings":"Configurações do Instagram","posts.form.instagram.posting_to":"Publicando em","posts.form.instagram.variant_label":"Tipo de publicação","posts.form.instagram.variant.feed":"Post","posts.form.instagram.variant.reel":"Reel","posts.form.instagram.variant.story":"Story","posts.form.instagram.aspect_label":"Proporção","posts.form.instagram.aspect.square":"Quadrado (1:1)","posts.form.instagram.aspect.portrait":"Retrato (4:5)","posts.form.instagram.aspect.landscape":"Paisagem (16:9)","posts.form.instagram.aspect.original":"Original","posts.form.facebook.settings":"Configurações do Facebook","posts.form.facebook.posting_to":"Publicando em","posts.form.facebook.variant_label":"Tipo de publicação","posts.form.facebook.variant.post":"Post","posts.form.facebook.variant.reel":"Reel","posts.form.facebook.variant.story":"Story","posts.form.linkedin.settings":"Configurações do LinkedIn","posts.form.linkedin.settings_page":"Configurações da Página do LinkedIn","posts.form.linkedin.posting_to":"Publicando em","posts.form.linkedin.variant_label":"Tipo de publicação","posts.form.linkedin.variant.post":"Post","posts.form.linkedin.variant.carousel":"Carrossel","posts.form.pinterest.settings":"Configurações do Pinterest","posts.form.pinterest.posting_to":"Publicando em","posts.form.pinterest.variant_label":"Tipo de pin","posts.form.pinterest.variant.pin":"Pin","posts.form.pinterest.variant.video_pin":"Video Pin","posts.form.pinterest.variant.carousel":"Carrossel","posts.form.pinterest.board":"Quadro","posts.form.pinterest.select_board":"Selecione um quadro","posts.form.pinterest.no_boards":"Nenhum quadro do Pinterest encontrado. Crie um na sua conta do Pinterest primeiro.","posts.form.pinterest.search_board":"Pesquisar quadros...","posts.form.pinterest.no_board_found":"Nenhum quadro encontrado.","posts.form.pinterest.board_required":"Selecione um quadro do Pinterest para publicar este post.","posts.form.warnings.no_variant":"Escolha um tipo de publicação para continuar.","posts.form.warnings.requires_media":"Este tipo exige pelo menos uma imagem ou vídeo.","posts.form.warnings.max_files_exceeded":"Este tipo aceita até :max arquivos (você tem :current).","posts.form.warnings.min_files_required":"Este tipo exige pelo menos :min arquivos (você tem :current).","posts.form.warnings.no_video_allowed":"Este tipo não aceita vídeos.","posts.form.warnings.no_image_allowed":"Este tipo aceita apenas vídeos.","posts.form.warnings.gif_not_allowed":"Esta rede não aceita GIF. Remova o GIF ou escolha outra rede.","posts.form.warnings.image_too_large":"A imagem passa do limite de :max (a sua tem :current).","posts.form.warnings.video_too_large":"O vídeo passa do limite de :max (o seu tem :current).","posts.form.warnings.video_too_long":"O vídeo dura :current, mas este tipo permite no máximo :max.","posts.form.warnings.aspect_ratio_too_narrow":"A proporção :current está muito alta (mínimo :min).","posts.form.warnings.aspect_ratio_too_wide":"A proporção :current está muito larga (máximo :max).","posts.status.pending":"Pendente","posts.status.draft":"Rascunho","posts.status.scheduled":"Agendado","posts.status.publishing":"Publicando","posts.status.retrying":"Tentando novamente","posts.status.published":"Publicado","posts.status.partially_published":"Parcialmente Publicado","posts.status.failed":"Falhou","posts.descriptions.draft":"Posts aguardando agendamento","posts.descriptions.scheduled":"Posts agendados para publicação","posts.descriptions.published":"Posts já publicados","posts.ai.generate.button_tooltip":"Gerar com IA","posts.ai.generate.title":"Gerar post com IA","posts.ai.generate.description":"Descreva sobre o que o post deve ser. A IA vai usar o contexto da sua marca pra escrever.","posts.ai.generate.prompt_label":"Sobre o que é esse post?","posts.ai.generate.prompt_placeholder":"ex: anunciar nossa nova feature de geração de imagens pra carrosséis","posts.ai.generate.preview_label":"Prévia","posts.ai.generate.start":"Gerar","posts.ai.generate.apply":"Usar este conteúdo","posts.ai.generate.retry":"Tentar de novo","posts.ai.generate.cancel":"Cancelar","posts.ai.review.button_tooltip":"Revisar com IA","posts.ai.review.title":"Revisar post com IA","posts.ai.review.description":"A IA analisa gramática, ortografia e clareza. Aplique as sugestões com as quais você concorda.","posts.ai.review.loading":"Revisando seu texto...","posts.ai.review.no_issues":"Nenhum problema encontrado. Tudo certo.","posts.ai.review.original":"Original","posts.ai.review.suggestion":"Sugestão","posts.ai.review.apply":"Aplicar","posts.ai.review.apply_all":"Aplicar todas","posts.ai.review.applied":"Aplicada","posts.ai.review.cancel":"Cancelar","posts.show.title":"Detalhes do Post","posts.show.edit":"Editar","posts.show.back":"Voltar","posts.show.no_content":"Sem legenda","posts.show.platforms":"Plataformas","posts.show.no_platforms":"Nenhuma plataforma selecionada.","posts.show.view_on_platform":"Ver na plataforma","posts.show.published_on":"Publicado em :date","posts.show.scheduled_for":"Agendado para :date","posts.show.draft":"Rascunho","posts.show.status_pending":"Pendente","posts.show.metrics":"Métricas","posts.show.metrics_loading":"Carregando métricas…","posts.show.metrics_unavailable":"Métricas ainda não disponíveis para esta plataforma.","posts.show.metrics_empty":"Nenhuma métrica retornada.","posts.edit.title":"Editar Post","posts.edit.view_title":"Visualizar Post","posts.edit.labels":"Etiqueta","posts.edit.signatures":"Assinaturas","posts.edit.schedule":"Agendar","posts.edit.delete":"Excluir","posts.edit.schedule_for":"Agendar para","posts.edit.unschedule":"Desagendar","posts.edit.saving":"Salvando...","posts.edit.saved":"Salvo","posts.edit.draft":"Rascunho","posts.edit.media":"Mídia","posts.edit.add_media":"Adicionar mídia","posts.edit.caption":"Legenda","posts.edit.caption_placeholder":"Escreva sua legenda...","posts.edit.compose_title":"Crie um post","posts.edit.compose_subtitle":"Componha sua mensagem e adicione mídia","posts.edit.preview_empty.title":"Nenhuma plataforma selecionada","posts.edit.preview_empty.description":"Selecione uma plataforma para publicar e ver o preview.","posts.edit.drop_zone_title":"Adicionar mídia","posts.edit.drop_zone_subtitle":"Arraste arquivos ou clique para selecionar","posts.edit.add":"Adicionar","posts.edit.publish_to":"Publicar em","posts.edit.organize":"Organizar","posts.edit.no_labels":"Nenhuma etiqueta criada ainda","posts.edit.pick_time":"Escolher horário","posts.edit.post_now":"Publicar agora","posts.edit.time":"Horário","posts.edit.cancel":"Cancelar","posts.edit.schedule_date":"Data de agendamento","posts.edit.view_on_platform":"Ver na plataforma","posts.edit.platform_status":"Status da plataforma","posts.edit.compliance_incomplete":"Algumas configurações de plataforma estão incompletas ou incompatíveis com a mídia anexada.","posts.edit.compliance.requires_media":"Adicione uma imagem ou vídeo para publicar aqui.","posts.edit.compliance.too_many_files":"Apenas :max arquivo(s) permitido(s) para este formato.","posts.edit.compliance.too_few_files":"Adicione pelo menos :min arquivos para este formato.","posts.edit.compliance.no_videos":"Apenas imagens são permitidas neste formato.","posts.edit.compliance.no_images":"Apenas vídeos são permitidos neste formato.","posts.edit.compliance.no_gifs":"GIFs não são suportados aqui.","posts.edit.compliance.video_too_large":"Vídeo excede o limite de tamanho desta plataforma.","posts.edit.compliance.video_too_long":"Vídeo deve ter menos de :seconds segundos neste formato.","posts.edit.compliance.image_too_large":"Imagem excede o limite de tamanho desta plataforma.","posts.edit.compliance.aspect_ratio_invalid":"A proporção da imagem não é suportada por este formato.","posts.edit.compliance.no_content_type":"Escolha um tipo de conteúdo para esta plataforma.","posts.edit.publishing":"Publicando...","posts.edit.publishing_overlay_title":"Seu post está sendo publicado","posts.edit.publishing_overlay_subtitle":"Isso pode levar alguns instantes. Você pode sair desta página sem problemas.","posts.edit.scheduled_overlay_title":"Este post está agendado","posts.edit.scheduled_overlay_subtitle":"Agendado para :date. Desagende para fazer alterações.","posts.edit.unschedule_cta":"Desagendar para editar","posts.edit.tabs.preview":"Pré-visualização","posts.edit.tabs.schedule":"Agendamento","posts.edit.tabs.comments":"Comentários","posts.edit.tabs.comments_empty":"Nenhum comentário ainda.","posts.edit.media_picker.title":"Escolher da galeria","posts.edit.media_picker.search":"Buscar mídia...","posts.edit.media_picker.empty":"Sua galeria ainda está vazia","posts.edit.media_picker.cancel":"Cancelar","posts.edit.media_picker.add":"Adicionar","posts.edit.media_picker.add_count":"Adicionar :count","posts.edit.emoji_picker.search":"Buscar emoji","posts.edit.emoji_picker.empty":"Nenhum emoji encontrado","posts.edit.emoji_picker.recent":"Usados recentemente","posts.edit.emoji_picker.smileys":"Sorrisos e emoções","posts.edit.emoji_picker.people":"Pessoas e corpo","posts.edit.emoji_picker.nature":"Animais e natureza","posts.edit.emoji_picker.food":"Comidas e bebidas","posts.edit.emoji_picker.activities":"Atividades","posts.edit.emoji_picker.travel":"Viagens e lugares","posts.edit.emoji_picker.objects":"Objetos","posts.edit.emoji_picker.symbols":"Símbolos","posts.edit.emoji_picker.flags":"Bandeiras","posts.edit.status.scheduled":"Agendado","posts.edit.status.published":"Publicado","posts.edit.status.publishing":"Publicando...","posts.edit.status.retrying":"Tentando novamente...","posts.edit.status.failed":"Falhou","posts.edit.delete_modal.title":"Excluir Post","posts.edit.delete_modal.description":"Tem certeza que deseja excluir este post? Esta ação não pode ser desfeita.","posts.edit.delete_modal.action":"Excluir","posts.edit.delete_modal.cancel":"Cancelar","posts.edit.sync_enable.title":"Ativar sincronização?","posts.edit.sync_enable.description":"Todas as plataformas compartilharão o mesmo conteúdo. Qualquer edição personalizada feita em plataformas individuais será substituída pelo conteúdo atual.","posts.edit.sync_enable.cancel":"Cancelar","posts.edit.sync_enable.action":"Ativar sincronização","posts.edit.sync_disable.title":"Desativar sincronização?","posts.edit.sync_disable.description":"Cada plataforma manterá seu conteúdo atual, mas edições futuras serão aplicadas apenas à plataforma que você estiver editando.","posts.edit.sync_disable.customize_note":"Você poderá personalizar o conteúdo para cada plataforma individualmente.","posts.edit.sync_disable.cancel":"Cancelar","posts.edit.sync_disable.action":"Desativar sincronização","posts.edit.platforms_dialog.title":"Selecionar Plataformas","posts.edit.platforms_dialog.description":"Escolha em quais plataformas publicar este post.","posts.edit.signatures_modal.search":"Buscar assinaturas...","posts.edit.signatures_modal.no_results":"Nenhuma assinatura encontrada.","posts.edit.validation.select_board":"Selecione uma pasta","posts.edit.validation.images_not_supported":"Imagens não suportadas","posts.edit.validation.videos_not_supported":"Vídeos não suportados","posts.edit.validation.max_images":"Máx :count imagens","posts.edit.validation.requires_media":"Requer mídia","posts.edit.validation.requires_content":"Texto é obrigatório","posts.edit.validation.exceeded":":count excedido","posts.edit.validation.does_not_support_images":":platform não suporta imagens","posts.edit.validation.supports_up_to_images":":platform suporta até :count imagens","posts.edit.validation.does_not_support_videos":":platform não suporta vídeos","posts.content_types.instagram_feed.label":"Post do Feed","posts.content_types.instagram_feed.description":"Aparece no seu feed e perfil","posts.content_types.instagram_reel.label":"Reels","posts.content_types.instagram_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.instagram_story.label":"Story","posts.content_types.instagram_story.description":"Desaparece após 24 horas","posts.content_types.linkedin_post.label":"Post","posts.content_types.linkedin_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_carousel.label":"Carrossel","posts.content_types.linkedin_carousel.description":"Imagens deslizáveis","posts.content_types.linkedin_page_post.label":"Post","posts.content_types.linkedin_page_post.description":"Post padrão com texto e mídia","posts.content_types.linkedin_page_carousel.label":"Carrossel","posts.content_types.linkedin_page_carousel.description":"Imagens deslizáveis","posts.content_types.facebook_post.label":"Post","posts.content_types.facebook_post.description":"Post padrão na sua página","posts.content_types.facebook_reel.label":"Reels","posts.content_types.facebook_reel.description":"Vídeo curto de até 90 segundos","posts.content_types.facebook_story.label":"Story","posts.content_types.facebook_story.description":"Desaparece após 24 horas","posts.content_types.tiktok_video.label":"Vídeo","posts.content_types.tiktok_video.description":"Conteúdo de vídeo curto","posts.content_types.tiktok_photo.label":"Carrossel de fotos","posts.content_types.tiktok_photo.description":"Até 35 fotos em um carrossel deslizável","posts.content_types.youtube_short.label":"Short","posts.content_types.youtube_short.description":"Vídeo vertical de até 60 segundos","posts.content_types.x_post.label":"Post","posts.content_types.x_post.description":"Tweet com texto e mídia","posts.content_types.threads_post.label":"Post","posts.content_types.threads_post.description":"Post de texto com mídia opcional","posts.content_types.pinterest_pin.label":"Pin","posts.content_types.pinterest_pin.description":"Pin de imagem padrão","posts.content_types.pinterest_video_pin.label":"Pin de Vídeo","posts.content_types.pinterest_video_pin.description":"Pin de vídeo (4s - 15min)","posts.content_types.pinterest_carousel.label":"Carrossel","posts.content_types.pinterest_carousel.description":"Carrossel multi-imagem (2-5 imagens)","posts.content_types.bluesky_post.label":"Post","posts.content_types.bluesky_post.description":"Post de texto com imagens opcionais","posts.content_types.mastodon_post.label":"Post","posts.content_types.mastodon_post.description":"Post de texto com mídia opcional","posts.platforms.linkedin":"LinkedIn","posts.platforms.linkedin-page":"Página do LinkedIn","posts.platforms.x":"X","posts.platforms.tiktok":"TikTok","posts.platforms.youtube":"YouTube Shorts","posts.platforms.facebook":"Página do Facebook","posts.platforms.instagram":"Instagram","posts.platforms.threads":"Threads","posts.platforms.pinterest":"Pinterest","posts.platforms.bluesky":"Bluesky","posts.platforms.mastodon":"Mastodon","posts.flash.scheduled":"Post agendado com sucesso!","posts.flash.deleted":"Post excluído com sucesso!","posts.flash.duplicated":"Post duplicado como rascunho.","posts.flash.cannot_edit_published":"Posts publicados não podem ser editados.","posts.flash.cannot_delete_published":"Posts publicados não podem ser excluídos.","posts.flash.connect_first":"Conecte pelo menos uma rede social antes de criar um post.","posts.errors.account_disconnected":"Conta social está desconectada","posts.errors.account_inactive":"Conta social está desativada","posts.errors.account_token_expired":"Sessão da conta social expirou — reconecte a conta","posts.delete.title":"Excluir post?","posts.delete.description":"Esta ação não pode ser desfeita. O post e todas as suas mídias serão removidos permanentemente.","posts.delete.confirm":"Sim, excluir","posts.delete.cancel":"Cancelar","posts.create.title":"Criar novo post","posts.create.description":"Escolha como quer começar.","posts.create.scratch_title":"Começar do zero","posts.create.scratch_description":"Abre um post em branco pra você escrever tudo.","posts.create.ai_title":"Gerar com IA","posts.create.ai_description":"Descreva o que quer e a IA gera o conteúdo pra você.","posts.create.ai_configure_description":"Escolha o formato e descreva o post que quer criar.","posts.create.template_title":"Usar um template","posts.create.template_description":"Escolha um dos nossos templates e personalize.","posts.create.coming_soon":"Em breve","posts.create.preview.image_title":"Título da imagem","posts.create.preview.image_body":"Texto da imagem","posts.create.steps.format_title":"Escolha um formato","posts.create.steps.format_description":"Selecione o tipo de post que deseja criar.","posts.create.steps.account_title":"Escolha uma conta","posts.create.steps.account_description":"Selecione a conta social para publicar.","posts.create.steps.media_title":"Opções de mídia","posts.create.steps.media_carousel":"Quantos slides?","posts.create.steps.media_optional":"Incluir imagens?","posts.create.steps.media_optional_label":"Quantas imagens?","posts.create.steps.media_none":"Nenhuma","posts.create.steps.media_count_label":"Número de imagens","posts.create.steps.prompt_title":"Descreva seu post","posts.create.steps.prompt_label":"Sobre o que é este post?","posts.create.steps.prompt_placeholder":"Ex. Anunciar nossa nova função de carrossel para o Instagram","posts.create.steps.preview_error":"Algo deu errado. Por favor, tente novamente.","posts.create.steps.loading_page_title":"Gerando seu post","posts.create.steps.loading_eta":"Tempo estimado: cerca de :minutes.","posts.create.steps.loading_eta_minute_one":"1 minuto","posts.create.steps.loading_eta_minute_other":":count minutos","posts.create.steps.loading_leave_title":"Você pode continuar trabalhando.","posts.create.steps.loading_leave_body":"A gente te avisa assim que o post ficar pronto.","posts.create.steps.loading_leave_cta":"Ir pro calendário","posts.create.steps.loading_create_another_cta":"Criar outro post","posts.create.steps.loading_tip_credits":"Cada imagem AI consome cerca de 15 créditos.","posts.create.steps.loading_tip_edit":"Você poderá editar tudo quando o post ficar pronto.","posts.create.steps.loading_tip_draft":"Posts gerados vão direto pros seus rascunhos.","posts.create.steps.loading_tip_brand":"Ajuste sua marca pra influenciar os próximos posts.","posts.create.steps.loading_tip_carousel":"Carrosséis geram um slide por imagem solicitada.","posts.create.steps.loading_tip_quality":"A qualidade equilibra velocidade e custo.","posts.create.steps.create":"Criar post","posts.create.steps.back":"Voltar","posts.create.steps.next":"Continuar","posts.create.steps.cancel":"Cancelar","posts.create.steps.discard":"Descartar","posts.create.steps.retry":"Tentar novamente","posts.create.steps.no_platforms":"Nenhuma conta conectada","posts.create.steps.connect_first":"Conecte pelo menos uma conta social para usar a geração com IA.","posts.create.steps.format.instagram_feed":"Post no Feed do Instagram","posts.create.steps.format.instagram_carousel":"Carrossel do Instagram","posts.create.steps.format.linkedin_post":"Post no LinkedIn","posts.create.steps.format.linkedin_page_post":"Post em Página do LinkedIn","posts.create.steps.format.x_post":"Post no X","posts.create.steps.format.bluesky_post":"Post no Bluesky","posts.create.steps.format.threads_post":"Post no Threads","posts.create.steps.format.mastodon_post":"Post no Mastodon","posts.create.steps.format.facebook_post":"Post no Facebook","posts.create.steps.format.pinterest_pin":"Pin no Pinterest","posts.create.steps.format.instagram_story":"Story do Instagram","posts.create.steps.format.facebook_story":"Story do Facebook","posts.templates.browser_title":"Escolha um template","posts.templates.browser_description":"Comece com um template pronto e adapte ao seu jeito.","posts.templates.all_platforms":"Todas as plataformas","posts.templates.platform_search_placeholder":"Buscar plataforma…","posts.templates.no_platform_match":"Nenhuma plataforma corresponde.","posts.templates.use_this":"Usar este template","posts.templates.no_templates":"Nenhum template disponível.","posts.templates.applying":"Aplicando template…","posts.templates.search_placeholder":"Buscar templates…","posts.templates.no_search_results":"Nenhum template encontrado","posts.templates.try_different_search":"Tente outra palavra-chave ou limpe a busca.","posts.templates.slides_count":"{count} slide|{count} slides","posts.templates.category.product_launch":"Lançamento de produto","posts.templates.category.promotion":"Promoção","posts.templates.category.educational":"Educacional","posts.templates.category.behind_the_scenes":"Bastidores","posts.templates.category.testimonial":"Depoimento","posts.templates.category.industry_tip":"Dica do setor","posts.templates.category.event":"Evento","posts.templates.category.engagement":"Engajamento","settings.title":"Configurações","settings.description":"Gerencie seu perfil e configurações da conta","settings.hub.title":"Configurações","settings.hub.description":"Escolha o que você quer gerenciar.","settings.hub.profile.title":"Perfil","settings.hub.profile.description":"Atualize suas informações pessoais, senha e preferências de notificações.","settings.hub.workspace.title":"Workspace","settings.hub.workspace.description":"Configure seu workspace, marca, membros e chaves de API.","settings.hub.account.title":"Conta","settings.hub.account.description":"Gerencie informações da conta, uso e faturamento.","settings.nav.profile":"Perfil","settings.nav.authentication":"Autenticação","settings.nav.workspace":"Workspace","settings.nav.members":"Membros","settings.nav.notifications":"Notificações","settings.nav.billing":"Faturamento","settings.notifications.title":"Preferências de notificações","settings.notifications.heading":"Notificações por e-mail","settings.notifications.description":"Escolha quais notificações por e-mail deseja receber","settings.notifications.post_published":"Post publicado","settings.notifications.post_published_description":"Receber um e-mail quando seu post for publicado com sucesso","settings.notifications.post_failed":"Post falhou","settings.notifications.post_failed_description":"Receber um e-mail quando seu post falhar ao publicar","settings.notifications.account_disconnected":"Conta desconectada","settings.notifications.account_disconnected_description":"Receber um e-mail quando uma conta social for desconectada","settings.notifications.save":"Salvar preferências","settings.profile.title":"Configurações do perfil","settings.profile.photo_heading":"Foto do perfil","settings.profile.photo_description":"Envie uma foto de perfil","settings.profile.heading":"Informações do perfil","settings.profile.description":"Atualize seu nome e endereço de e-mail","settings.profile.avatar":"Avatar","settings.profile.name":"Nome","settings.profile.name_placeholder":"Nome completo","settings.profile.email":"Endereço de e-mail","settings.profile.email_placeholder":"Endereço de e-mail","settings.profile.email_unverified":"Seu endereço de e-mail não foi verificado.","settings.profile.resend_verification":"Clique aqui para reenviar o e-mail de verificação.","settings.profile.verification_sent":"Um novo link de verificação foi enviado para seu endereço de e-mail.","settings.profile.save":"Salvar","settings.authentication.title":"Autenticação","settings.authentication.page_title":"Configurações de autenticação","settings.authentication.sessions.title":"Sessões ativas","settings.authentication.sessions.description":"Se você notar algo suspeito, encerre as sessões em outros dispositivos.","settings.authentication.sessions.unknown_browser":"Navegador desconhecido","settings.authentication.sessions.unknown_ip":"IP desconhecido","settings.authentication.sessions.on":"em","settings.authentication.sessions.active_now":"Ativa agora","settings.authentication.sessions.log_out_others":"Encerrar outras sessões","settings.authentication.sessions.modal_title":"Encerrar outras sessões","settings.authentication.sessions.modal_description_password":"Digite sua senha atual para confirmar o encerramento das outras sessões.","settings.authentication.sessions.modal_description_email":"Digite seu e-mail para confirmar o encerramento das outras sessões.","settings.authentication.sessions.password_placeholder":"Senha atual","settings.authentication.sessions.email_placeholder":"Seu e-mail","settings.authentication.sessions.cancel":"Cancelar","settings.authentication.sessions.submit":"Encerrar outras sessões","settings.authentication.sessions.email_mismatch":"O e-mail não corresponde à sua conta.","settings.authentication.sessions.flash_logged_out":"Outras sessões foram encerradas.","settings.authentication.password.update_title":"Atualizar senha","settings.authentication.password.set_title":"Definir uma senha","settings.authentication.password.update_description":"Use uma senha longa e aleatória para manter sua conta segura.","settings.authentication.password.set_description":"Adicione uma senha para entrar sem precisar de um provedor conectado.","settings.authentication.password.current_password":"Senha atual","settings.authentication.password.new_password":"Nova senha","settings.authentication.password.confirm_password":"Confirmar senha","settings.authentication.password.save":"Salvar senha","settings.authentication.password.set":"Definir senha","settings.authentication.providers.title":"Contas conectadas","settings.authentication.providers.description":"Faça login mais rápido usando esses provedores conectados.","settings.authentication.providers.connected":"Conectada","settings.authentication.providers.not_connected":"Não conectada","settings.authentication.providers.connect":"Conectar","settings.authentication.providers.disconnect":"Desconectar","settings.authentication.providers.flash_disconnected":":provider desconectada com sucesso.","settings.authentication.providers.flash_connected":":provider conectada com sucesso.","settings.authentication.providers.flash_already_linked":"Essa conta do :provider já está vinculada a outro usuário.","settings.authentication.providers.flash_cannot_disconnect":"Você não pode desconectar seu único método de login. Defina uma senha ou conecte outro provedor primeiro.","settings.delete_account.heading":"Excluir conta","settings.delete_account.description":"Exclua sua conta e todos os seus recursos","settings.delete_account.warning":"Atenção","settings.delete_account.warning_message":"Por favor, prossiga com cuidado, isso não pode ser desfeito.","settings.delete_account.button":"Excluir conta","settings.delete_account.modal_title":"Tem certeza que deseja excluir sua conta?","settings.delete_account.modal_description_password":"Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite sua senha para confirmar.","settings.delete_account.modal_description_email":"Uma vez excluída, todos os seus recursos e dados também serão permanentemente removidos. Digite o seu e-mail :email para confirmar.","settings.delete_account.password":"Senha","settings.delete_account.password_placeholder":"Senha","settings.delete_account.email_placeholder":"Seu e-mail","settings.delete_account.email_mismatch":"O e-mail não corresponde à sua conta.","settings.delete_account.cancel":"Cancelar","settings.delete_account.confirm":"Excluir conta","settings.workspace.tabs.workspace":"Workspace","settings.workspace.tabs.brand":"Marca","settings.workspace.tabs.users":"Membros","settings.workspace.tabs.api_keys":"API Keys","settings.workspace.title":"Configurações do workspace","settings.workspace.logo_heading":"Logo do workspace","settings.workspace.logo_description":"Envie um logo para o workspace","settings.workspace.heading":"Nome do workspace","settings.workspace.description":"Atualize o nome do workspace","settings.workspace.members_heading":"Membros","settings.workspace.members_description":"Gerencie membros e convites do workspace","settings.workspace.name":"Nome","settings.workspace.name_placeholder":"Meu Workspace","settings.workspace.save":"Salvar","settings.brand.title":"Marca","settings.brand.description":"Configure a identidade da sua marca para os conteúdos gerados por AI.","settings.brand.name":"Nome do workspace","settings.brand.name_placeholder":"Minha marca","settings.brand.website":"Site","settings.brand.website_placeholder":"https://suamarca.com","settings.brand.brand_description":"Descrição","settings.brand.brand_description_placeholder":"Conte sobre sua marca, o que você faz e quem é seu público...","settings.brand.tone":"Tom de voz","settings.brand.tone_professional":"Profissional","settings.brand.tone_casual":"Casual","settings.brand.tone_friendly":"Amigável","settings.brand.tone_bold":"Ousado","settings.brand.tone_inspirational":"Inspirador","settings.brand.tone_humorous":"Bem-humorado","settings.brand.tone_educational":"Educacional","settings.brand.voice_notes":"Notas de voz","settings.brand.voice_notes_placeholder":"Diretrizes adicionais de escrita, palavras a evitar, preferências de estilo...","settings.brand.brand_color":"Cor da marca","settings.brand.background_color":"Cor de fundo","settings.brand.text_color":"Cor do texto","settings.brand.font":"Fonte","settings.brand.image_style":"Estilo das imagens","settings.brand.image_style_description":"Estilo visual aplicado ao gerar imagens de slides e capas para posts com AI.","settings.brand.image_style_cinematic":"Cinematográfico","settings.brand.image_style_illustration":"Ilustração","settings.brand.image_style_isometric_3d":"Isométrico","settings.brand.image_style_cartoon":"Cartoon","settings.brand.image_style_typographic":"Tipográfico","settings.brand.image_style_infographic":"Infográfico","settings.brand.image_style_minimalist":"Minimalista","settings.brand.image_style_mockup":"Mockup","settings.brand.content_language":"Idioma do conteúdo","settings.brand.content_language_description":"Idioma usado nas legendas, hashtags e em qualquer texto dentro de imagens ou vídeos gerados por AI.","settings.members.title":"Membros","settings.members.heading":"Membros da equipe","settings.members.description":"Gerencie membros e convites deste workspace","settings.members.cancel":"Cancelar","settings.members.remove":"Remover","settings.members.make_admin":"Tornar administrador","settings.members.make_member":"Tornar membro","settings.members.invite.title":"Convidar Membro","settings.members.invite.description":"Envie um convite por e-mail para adicionar colaboradores","settings.members.invite.email":"E-mail","settings.members.invite.email_placeholder":"colaborador@email.com","settings.members.invite.role":"Função","settings.members.invite.role_placeholder":"Selecione uma função","settings.members.invite.submit":"Enviar Convite","settings.members.pending.title":"Convites Pendentes","settings.members.pending.description":"Convites aguardando aceitação","settings.members.pending.empty":"Nenhum convite pendente","settings.members.list.title":"Membros","settings.members.list.description":"Pessoas com acesso a este workspace","settings.members.list.empty":"Nenhum membro além do proprietário","settings.members.remove_modal.title":"Remover membro","settings.members.remove_modal.description":"Tem certeza que deseja remover este membro do workspace? Ele perderá acesso a todos os recursos do workspace.","settings.members.remove_modal.action":"Remover membro","settings.members.cancel_invite_modal.title":"Cancelar convite","settings.members.cancel_invite_modal.description":"Tem certeza que deseja cancelar este convite?","settings.members.cancel_invite_modal.action":"Cancelar convite","settings.members.roles.owner":"Proprietário","settings.members.roles.admin":"Administrador","settings.members.roles.member":"Membro","settings.members.roles.viewer":"Visualizador","settings.members.flash.invite_sent":"Convite enviado com sucesso!","settings.members.flash.invite_deleted":"Convite excluído.","settings.members.flash.member_removed":"Membro removido com sucesso.","settings.members.flash.role_updated":"Função do membro atualizada.","settings.members.flash.wrong_email":"Este convite é para um endereço de e-mail diferente.","settings.members.flash.already_member":"Você já é membro deste workspace.","settings.members.flash.invite_accepted":"Bem-vindo! Você agora é membro do workspace.","settings.members.flash.invite_declined":"Convite recusado.","settings.account.tabs.account":"Conta","settings.account.tabs.usage":"Uso","settings.account.tabs.billing":"Faturamento","settings.account.title":"Configurações da conta","settings.account.description":"Gerencie o nome da conta e o e-mail de cobrança","settings.account.name":"Nome da conta","settings.account.name_placeholder":"Minha Empresa","settings.account.billing_email":"E-mail de cobrança","settings.account.billing_email_placeholder":"cobranca@empresa.com","settings.account.billing_email_hint":"Este e-mail será usado para faturas e comunicações de cobrança do Stripe.","settings.account.submit":"Salvar","settings.flash.account_updated":"Conta atualizada com sucesso!","settings.flash.profile_updated":"Perfil atualizado com sucesso!","settings.flash.language_updated":"Idioma atualizado com sucesso!","settings.flash.password_updated":"Senha atualizada com sucesso!","settings.flash.workspace_updated":"Configurações atualizadas com sucesso!","settings.flash.photo_updated":"Foto atualizada com sucesso!","settings.flash.photo_deleted":"Foto removida com sucesso!","settings.flash.logo_updated":"Logo enviado com sucesso!","settings.flash.logo_deleted":"Logo removido com sucesso!","settings.flash.notifications_updated":"Preferências de notificações atualizadas!","settings.api_keys.title":"Chaves API","settings.api_keys.page_title":"Chaves API","settings.api_keys.heading":"Chaves API","settings.api_keys.description":"Gerencie chaves API para acesso programático ao seu workspace.","settings.api_keys.create":"Criar chave API","settings.api_keys.copy":"Copiar","settings.api_keys.new_token_message":"Sua nova chave API foi criada. Copie agora — você não poderá vê-la novamente.","settings.api_keys.table.name":"Nome","settings.api_keys.table.key":"Chave","settings.api_keys.table.status":"Status","settings.api_keys.table.expires":"Expira","settings.api_keys.table.last_used":"Último uso","settings.api_keys.table.never":"Nunca","settings.api_keys.actions.copy_id":"Copiar ID da chave API","settings.api_keys.actions.copy_id_success":"ID da chave API copiado","settings.api_keys.actions.delete":"Excluir","settings.api_keys.empty.title":"Nenhuma chave API","settings.api_keys.empty.description":"Crie uma chave API para acessar seu workspace programaticamente.","settings.api_keys.delete_modal.title":"Excluir chave API","settings.api_keys.delete_modal.description":"Tem certeza que deseja excluir esta chave API? Aplicações que a usam perderão acesso imediatamente.","settings.api_keys.delete_modal.action":"Excluir chave API","settings.api_keys.create_dialog.title":"Criar chave API","settings.api_keys.create_dialog.description":"Crie uma nova chave API para acesso programático ao seu workspace.","settings.api_keys.create_dialog.name":"Nome","settings.api_keys.create_dialog.name_placeholder":"ex. Chave API de Produção","settings.api_keys.create_dialog.expires":"Data de expiração (opcional)","settings.api_keys.create_dialog.expires_placeholder":"Sem expiração","settings.api_keys.create_dialog.submit":"Criar","settings.api_keys.create_dialog.cancel":"Cancelar","settings.api_keys.flash.created":"Chave de API criada com sucesso!","settings.api_keys.flash.deleted":"Chave de API excluída com sucesso!","sidebar.workspaces":"Espaços de trabalho","sidebar.select_workspace":"Selecionar workspace","sidebar.create_workspace":"Criar workspace","sidebar.create_post":"Novo post","sidebar.profile":"Perfil","sidebar.log_out":"Sair","sidebar.workspace.connections":"Conexões","sidebar.workspace.signatures":"Assinaturas","sidebar.workspace.labels":"Etiquetas","sidebar.workspace.assets":"Mídias","sidebar.workspace.api_keys":"API Keys","sidebar.workspace_select":"Workspace: Selecionar","sidebar.theme":"Tema: :name","sidebar.theme_light":"Claro","sidebar.theme_dark":"Escuro","sidebar.theme_system":"Sistema","sidebar.language":"Idioma: :name","sidebar.language_select":"Idioma: Selecionar","sidebar.groups.posts":"Posts","sidebar.groups.workspace":"Workspace","sidebar.groups.support":"Suporte","sidebar.analytics":"Analytics","sidebar.settings":"Configurações","sidebar.posts.calendar":"Calendário","sidebar.posts.all":"Todos","sidebar.posts.scheduled":"Agendados","sidebar.posts.posted":"Publicados","sidebar.posts.drafts":"Rascunhos","sidebar.notifications":"Notificações","sidebar.mark_all_read":"Marcar tudo como lido","sidebar.mark_as_read":"Marcar como lido","sidebar.archive_all":"Arquivar tudo","sidebar.no_notifications":"Sem notificações","sidebar.support.discord":"Discord","sidebar.support.share_feedback":"Enviar feedback","sidebar.support.last_updates":"Últimas Atualizações","sidebar.support.docs":"Documentação","signatures.title":"Assinaturas","signatures.description":"Crie assinaturas reutilizáveis pra anexar rapidamente nos seus posts","signatures.search":"Buscar assinaturas...","signatures.new":"Nova assinatura","signatures.empty_title":"Nenhuma assinatura ainda","signatures.empty_description":"Crie assinaturas pra anexar hashtags, links ou qualquer texto reutilizável nos seus posts","signatures.no_search_results":"Nenhuma assinatura corresponde à busca","signatures.try_different_search":"Tente outra palavra-chave ou limpe a busca.","signatures.table.name":"Nome","signatures.table.content":"Conteúdo","signatures.table.created_at":"Criado em","signatures.actions.edit":"Editar assinatura","signatures.actions.delete":"Excluir assinatura","signatures.create.title":"Criar assinatura","signatures.create.description":"Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).","signatures.create.name":"Nome","signatures.create.name_placeholder":"ex: Marketing, Viagem, Encerramento da marca","signatures.create.content":"Conteúdo","signatures.create.content_placeholder":"#marketing #socialmedia\nSaiba mais: https://suamarca.com","signatures.create.content_hint":"Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.","signatures.create.submit":"Criar assinatura","signatures.create.submitting":"Criando...","signatures.edit.title":"Editar assinatura","signatures.edit.description":"Atualize o nome e o conteúdo desta assinatura.","signatures.edit.name":"Nome","signatures.edit.name_placeholder":"ex: Marketing, Viagem, Encerramento da marca","signatures.edit.content":"Conteúdo","signatures.edit.content_placeholder":"#marketing #socialmedia\nSaiba mais: https://suamarca.com","signatures.edit.content_hint":"Hashtags, links, intros, assinaturas — qualquer coisa que você anexa nos posts.","signatures.edit.submit":"Salvar alterações","signatures.edit.submitting":"Salvando...","signatures.delete.title":"Deletar assinatura","signatures.delete.description":"Tem certeza que quer deletar esta assinatura? Esta ação não pode ser desfeita.","signatures.delete.confirm":"Deletar","signatures.delete.cancel":"Cancelar","signatures.flash.created":"Assinatura criada.","signatures.flash.updated":"Assinatura atualizada.","signatures.flash.deleted":"Assinatura deletada.","usage.title":"Uso","usage.section_account":"Conta","usage.section_account_description":"Cotas e limites do seu plano :plan.","usage.section_ai":"Créditos AI","usage.section_ai_description":"Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.","usage.workspaces":"Workspaces","usage.social_accounts":"Contas Sociais","usage.members":"Membros","usage.credits":"Créditos","validation.accepted":"O campo :attribute deve ser aceito.","validation.accepted_if":"O campo :attribute deve ser aceito quando :other for :value.","validation.active_url":"O campo :attribute deve ser uma URL válida.","validation.after":"O campo :attribute deve ser uma data posterior a :date.","validation.after_or_equal":"O campo :attribute deve ser uma data posterior ou igual a :date.","validation.alpha":"O campo :attribute deve conter apenas letras.","validation.alpha_dash":"O campo :attribute deve conter apenas letras, números, hifens e underscores.","validation.alpha_num":"O campo :attribute deve conter apenas letras e números.","validation.any_of":"O campo :attribute é inválido.","validation.array":"O campo :attribute deve ser um array.","validation.ascii":"O campo :attribute deve conter apenas caracteres alfanuméricos e símbolos de um byte.","validation.before":"O campo :attribute deve ser uma data anterior a :date.","validation.before_or_equal":"O campo :attribute deve ser uma data anterior ou igual a :date.","validation.between.array":"O campo :attribute deve ter entre :min e :max itens.","validation.between.file":"O campo :attribute deve estar entre :min e :max kilobytes.","validation.between.numeric":"O campo :attribute deve estar entre :min e :max.","validation.between.string":"O campo :attribute deve estar entre :min e :max caracteres.","validation.boolean":"O campo :attribute deve ser verdadeiro ou falso.","validation.can":"O campo :attribute contém um valor não autorizado.","validation.confirmed":"A confirmação do campo :attribute não corresponde.","validation.contains":"O campo :attribute está faltando um valor obrigatório.","validation.current_password":"A senha está incorreta.","validation.date":"O campo :attribute deve ser uma data válida.","validation.date_equals":"O campo :attribute deve ser uma data igual a :date.","validation.date_format":"O campo :attribute deve corresponder ao formato :format.","validation.decimal":"O campo :attribute deve ter :decimal casas decimais.","validation.declined":"O campo :attribute deve ser recusado.","validation.declined_if":"O campo :attribute deve ser recusado quando :other for :value.","validation.different":"O campo :attribute e :other devem ser diferentes.","validation.digits":"O campo :attribute deve ter :digits dígitos.","validation.digits_between":"O campo :attribute deve ter entre :min e :max dígitos.","validation.dimensions":"O campo :attribute deve ter dimensões de imagem válidas.","validation.distinct":"O campo :attribute tem um valor duplicado.","validation.doesnt_contain":"O campo :attribute não deve conter nenhum dos seguintes: :values.","validation.doesnt_end_with":"O campo :attribute não deve terminar com nenhum dos seguintes: :values.","validation.doesnt_start_with":"O campo :attribute não deve começar com nenhum dos seguintes: :values.","validation.email":"O campo :attribute deve ser um endereço de e-mail válido.","validation.encoding":"O campo :attribute deve ser codificado em :encoding.","validation.ends_with":"O campo :attribute deve terminar com um dos seguintes: :values.","validation.enum":"O :attribute selecionado é inválido.","validation.exists":"O :attribute selecionado é inválido.","validation.extensions":"O campo :attribute deve ter uma das seguintes extensões: :values.","validation.file":"O campo :attribute deve ser um arquivo.","validation.filled":"O campo :attribute deve ter um valor.","validation.gt.array":"O campo :attribute deve ter mais de :value itens.","validation.gt.file":"O campo :attribute deve ser maior que :value kilobytes.","validation.gt.numeric":"O campo :attribute deve ser maior que :value.","validation.gt.string":"O campo :attribute deve ser maior que :value caracteres.","validation.gte.array":"O campo :attribute deve ter :value itens ou mais.","validation.gte.file":"O campo :attribute deve ser maior ou igual a :value kilobytes.","validation.gte.numeric":"O campo :attribute deve ser maior ou igual a :value.","validation.gte.string":"O campo :attribute deve ser maior ou igual a :value caracteres.","validation.hex_color":"O campo :attribute deve ser uma cor hexadecimal válida.","validation.image":"O campo :attribute deve ser uma imagem.","validation.in":"O :attribute selecionado é inválido.","validation.in_array":"O campo :attribute deve existir em :other.","validation.in_array_keys":"O campo :attribute deve conter pelo menos uma das seguintes chaves: :values.","validation.integer":"O campo :attribute deve ser um inteiro.","validation.ip":"O campo :attribute deve ser um endereço IP válido.","validation.ipv4":"O campo :attribute deve ser um endereço IPv4 válido.","validation.ipv6":"O campo :attribute deve ser um endereço IPv6 válido.","validation.json":"O campo :attribute deve ser uma string JSON válida.","validation.list":"O campo :attribute deve ser uma lista.","validation.lowercase":"O campo :attribute deve estar em minúsculas.","validation.lt.array":"O campo :attribute deve ter menos de :value itens.","validation.lt.file":"O campo :attribute deve ser menor que :value kilobytes.","validation.lt.numeric":"O campo :attribute deve ser menor que :value.","validation.lt.string":"O campo :attribute deve ser menor que :value caracteres.","validation.lte.array":"O campo :attribute deve ter :value itens ou menos.","validation.lte.file":"O campo :attribute deve ser menor ou igual a :value kilobytes.","validation.lte.numeric":"O campo :attribute deve ser menor ou igual a :value.","validation.lte.string":"O campo :attribute deve ser menor ou igual a :value caracteres.","validation.mac_address":"O campo :attribute deve ser um endereço MAC válido.","validation.max.array":"O campo :attribute deve ter no máximo :max itens.","validation.max.file":"O campo :attribute deve ter no máximo :max kilobytes.","validation.max.numeric":"O campo :attribute deve ter no máximo :max.","validation.max.string":"O campo :attribute deve ter no máximo :max caracteres.","validation.max_digits":"O campo :attribute não deve ter mais que :max dígitos.","validation.mimes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.mimetypes":"O campo :attribute deve ser um arquivo do tipo: :values.","validation.min.array":"O campo :attribute deve ter pelo menos :min itens.","validation.min.file":"O campo :attribute deve ter pelo menos :min kilobytes.","validation.min.numeric":"O campo :attribute deve ter pelo menos :min.","validation.min.string":"O campo :attribute deve ter pelo menos :min caracteres.","validation.min_digits":"O campo :attribute deve ter pelo menos :min dígitos.","validation.missing":"O campo :attribute deve estar ausente.","validation.missing_if":"O campo :attribute deve estar ausente quando :other for :value.","validation.missing_unless":"O campo :attribute deve estar ausente a menos que :other seja :value.","validation.missing_with":"O campo :attribute deve estar ausente quando :values estiver presente.","validation.missing_with_all":"O campo :attribute deve estar ausente quando :values estiverem presentes.","validation.multiple_of":"O campo :attribute deve ser um múltiplo de :value.","validation.not_in":"O :attribute selecionado é inválido.","validation.not_regex":"O formato do campo :attribute é inválido.","validation.numeric":"O campo :attribute deve ser um número.","validation.password.letters":"O campo :attribute deve conter pelo menos uma letra.","validation.password.mixed":"O campo :attribute deve conter pelo menos uma letra maiúscula e uma minúscula.","validation.password.numbers":"O campo :attribute deve conter pelo menos um número.","validation.password.symbols":"O campo :attribute deve conter pelo menos um símbolo.","validation.password.uncompromised":"O :attribute fornecido apareceu em um vazamento de dados. Por favor, escolha um :attribute diferente.","validation.present":"O campo :attribute deve estar presente.","validation.present_if":"O campo :attribute deve estar presente quando :other for :value.","validation.present_unless":"O campo :attribute deve estar presente a menos que :other seja :value.","validation.present_with":"O campo :attribute deve estar presente quando :values estiver presente.","validation.present_with_all":"O campo :attribute deve estar presente quando :values estiverem presentes.","validation.prohibited":"O campo :attribute é proibido.","validation.prohibited_if":"O campo :attribute é proibido quando :other for :value.","validation.prohibited_if_accepted":"O campo :attribute é proibido quando :other for aceito.","validation.prohibited_if_declined":"O campo :attribute é proibido quando :other for recusado.","validation.prohibited_unless":"O campo :attribute é proibido a menos que :other esteja em :values.","validation.prohibits":"O campo :attribute proíbe :other de estar presente.","validation.regex":"O formato do campo :attribute é inválido.","validation.required":"O campo :attribute é obrigatório.","validation.required_array_keys":"O campo :attribute deve conter entradas para: :values.","validation.required_if":"O campo :attribute é obrigatório quando :other for :value.","validation.required_if_accepted":"O campo :attribute é obrigatório quando :other for aceito.","validation.required_if_declined":"O campo :attribute é obrigatório quando :other for recusado.","validation.required_unless":"O campo :attribute é obrigatório a menos que :other esteja em :values.","validation.required_with":"O campo :attribute é obrigatório quando :values estiver presente.","validation.required_with_all":"O campo :attribute é obrigatório quando :values estiverem presentes.","validation.required_without":"O campo :attribute é obrigatório quando :values não estiver presente.","validation.required_without_all":"O campo :attribute é obrigatório quando nenhum dos :values estiver presente.","validation.same":"O campo :attribute deve ser igual a :other.","validation.size.array":"O campo :attribute deve conter :size itens.","validation.size.file":"O campo :attribute deve ter :size kilobytes.","validation.size.numeric":"O campo :attribute deve ser :size.","validation.size.string":"O campo :attribute deve ter :size caracteres.","validation.starts_with":"O campo :attribute deve começar com um dos seguintes: :values.","validation.string":"O campo :attribute deve ser uma string.","validation.timezone":"O campo :attribute deve ser um fuso horário válido.","validation.unique":"O :attribute já foi utilizado.","validation.uploaded":"O :attribute falhou ao ser enviado.","validation.uppercase":"O campo :attribute deve estar em maiúsculo.","validation.url":"O campo :attribute deve ser uma URL válida.","validation.ulid":"O campo :attribute deve ser um ULID válido.","validation.uuid":"O campo :attribute deve ser um UUID válido.","validation.custom.attribute-name.rule-name":"custom-message","workspaces.title":"Workspaces","workspaces.select_title":"Seus workspaces","workspaces.select_description":"Selecione um workspace para continuar","workspaces.current":"Atual","workspaces.connections":":count conexões","workspaces.posts":":count posts","workspaces.create.page_title":"Crie seu workspace","workspaces.create.title":"Configure seu workspace","workspaces.create.description":"Conte um pouco sobre você ou seu projeto. Vamos usar pra personalizar os posts gerados por IA com a sua voz.","workspaces.create.website":"Site","workspaces.create.website_placeholder":"https://suamarca.com","workspaces.create.autofill":"Preencher do site","workspaces.create.autofill_missing_url":"Informe uma URL primeiro.","workspaces.create.autofill_success":"Informações da marca carregadas.","workspaces.create.autofill_error":"Não foi possível preencher automaticamente. Você pode preencher os campos manualmente.","workspaces.create.autofill_errors.unreachable":"Não conseguimos acessar esse site (:reason).","workspaces.create.autofill_errors.http_status":"O site retornou um status inesperado (:status).","workspaces.create.autofill_errors.invalid_scheme":"Apenas URLs http e https são suportadas.","workspaces.create.autofill_errors.missing_host":"A URL está sem um host.","workspaces.create.autofill_errors.unresolvable_host":"Não conseguimos resolver o host (:host).","workspaces.create.autofill_errors.private_network":"URLs apontando para redes privadas não são permitidas.","workspaces.create.logo_captured":"Logo capturada do seu site.","workspaces.create.name":"Nome do workspace","workspaces.create.name_placeholder":"ex. Acme Inc","workspaces.create.brand_description":"Descrição da marca","workspaces.create.brand_description_placeholder":"O que sua marca faz?","workspaces.create.tone":"Tom da marca","workspaces.create.tone_professional":"Profissional","workspaces.create.tone_casual":"Casual","workspaces.create.tone_friendly":"Amigável","workspaces.create.tone_bold":"Ousado","workspaces.create.tone_inspirational":"Inspirador","workspaces.create.tone_humorous":"Bem-humorado","workspaces.create.tone_educational":"Educacional","workspaces.create.content_language":"Idioma do conteúdo","workspaces.create.content_language_description":"Legendas geradas por IA serão escritas neste idioma.","workspaces.create.voice_notes":"Notas de voz (opcional)","workspaces.create.voice_notes_placeholder":"ex. frases curtas e diretas. sem jargão.","workspaces.create.brand_color":"Cor da marca","workspaces.create.background_color":"Cor de fundo","workspaces.create.text_color":"Cor do texto","workspaces.create.submit":"Criar workspace","workspaces.create.success":"Workspace criado. Conecte uma conta social para começar a postar.","workspaces.limit_reached":"Você atingiu o limite de workspaces do seu plano.","workspaces.flash.deleted":"Workspace excluído com sucesso."} \ No newline at end of file diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index e36b4744..71b5c515 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -175,6 +175,7 @@ 'draft' => 'Rascunho', 'scheduled' => 'Agendado', 'publishing' => 'Publicando', + 'retrying' => 'Tentando novamente', 'published' => 'Publicado', 'partially_published' => 'Parcialmente Publicado', 'failed' => 'Falhou', @@ -323,6 +324,7 @@ 'scheduled' => 'Agendado', 'published' => 'Publicado', 'publishing' => 'Publicando...', + 'retrying' => 'Tentando novamente...', 'failed' => 'Falhou', ], diff --git a/resources/js/composables/usePostStatus.ts b/resources/js/composables/usePostStatus.ts index 401c5f81..b381d779 100644 --- a/resources/js/composables/usePostStatus.ts +++ b/resources/js/composables/usePostStatus.ts @@ -19,6 +19,7 @@ const CONFIGS: Record> = { draft: { variant: 'outline', icon: IconFileText }, scheduled: { variant: 'default', icon: IconClock }, publishing: { variant: 'warning', icon: IconLoader2 }, + retrying: { variant: 'warning', icon: IconLoader2 }, published: { variant: 'success', icon: IconCircleCheck }, partially_published: { variant: 'warning', icon: IconAlertCircle }, failed: { variant: 'destructive', icon: IconAlertCircle }, @@ -33,6 +34,7 @@ export const getPlatformStatusConfig = (status: string): StatusConfig => { const map: Record = { pending: 'draft', publishing: 'publishing', + retrying: 'retrying', published: 'published', failed: 'failed', }; diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index ecedf7af..863bf89f 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -7,9 +7,9 @@ use App\Enums\SocialAccount\Status as AccountStatus; use App\Enums\UserWorkspace\Role; use App\Events\PostPlatformStatusUpdated; +use App\Exceptions\PlatformUnavailableException; use App\Exceptions\Social\ErrorCategory; use App\Exceptions\Social\LinkedInPublishException; -use App\Exceptions\PlatformUnavailableException; use App\Exceptions\TokenExpiredException; use App\Jobs\PublishToSocialPlatform; use App\Jobs\SendNotification; @@ -20,6 +20,7 @@ use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; use App\Services\Social\LinkedInPublisher; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; @@ -110,14 +111,42 @@ expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired); }); -test('publish does NOT mark account expired when retry refresh hits platform unavailable', function () { +test('publish reschedules platform unavailable retry via Bus dispatch (not marked Failed, not expired)', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + ); + + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->socialAccount->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); + expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); + expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); + expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(1); + expect($this->socialAccount->status)->toBe(AccountStatus::Connected); + + Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) { + return $job->postPlatform->id === $this->postPlatform->id; + }); +}); + +test('publish reschedules retry when retry-refresh path hits platform unavailable', function () { + Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); // Publisher first throws TokenExpired (401-style), the retry-refresh // path goes through ConnectionVerifier::verify which can in turn raise - // PlatformUnavailable if the platform is down. The account must stay - // Connected — it was the platform that failed, not the token. + // PlatformUnavailable if the platform is down. $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401')); @@ -134,32 +163,34 @@ $this->postPlatform->refresh(); $this->socialAccount->refresh(); - expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); + expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); - expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); expect($this->socialAccount->status)->toBe(AccountStatus::Connected); + + Bus::assertDispatched(PublishToSocialPlatform::class); }); -test('publish to social platform does NOT mark account expired when platform is unavailable', function () { +test('publish retry count increments across successive platform_unavailable attempts', function () { + Bus::fake([PublishToSocialPlatform::class]); Event::fake(); Mail::fake(); $publisher = Mockery::mock(LinkedInPublisher::class); $publisher->shouldReceive('publish')->andThrow( - new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503) + new PlatformUnavailableException('LinkedIn 503', 503) ); - $this->app->instance(LinkedInPublisher::class, $publisher); + // Simulate prior attempts + $this->postPlatform->update([ + 'error_context' => ['retry_count' => 5], + ]); + (new PublishToSocialPlatform($this->postPlatform))->handle(); $this->postPlatform->refresh(); - $this->socialAccount->refresh(); - expect($this->postPlatform->status)->toBe(PlatformStatus::Failed); - expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable'); - expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503); - expect($this->socialAccount->status)->toBe(AccountStatus::Connected); + expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(6); }); test('publish to social platform updates post status when all platforms finished', function () { From 2509b6ee265053abefd7b6fb7c4272fb2ac6884f Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 10:14:49 -0300 Subject: [PATCH 39/65] test(social): plug remaining gaps around retry-reschedule behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit found three behaviors with no explicit assertion: - Job is dispatched with a 10-minute delay (would silently regress if the duration changed). Uses Carbon::setTestNow + Bus::assertDispatched inspecting \$job->delay. - error_context.last_attempt_at is recorded at the moment of failure. - updatePostStatus does NOT finalize the parent Post while any of its platforms is in Retrying — covers the central invariant of the feature (the post must stay Publishing until every platform lands in Published or Failed). - A platform currently in Retrying transitions to Published when the next attempt succeeds — proves the loop terminates. --- .../Jobs/PublishToSocialPlatformTest.php | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) diff --git a/tests/Feature/Jobs/PublishToSocialPlatformTest.php b/tests/Feature/Jobs/PublishToSocialPlatformTest.php index 863bf89f..92abd65a 100644 --- a/tests/Feature/Jobs/PublishToSocialPlatformTest.php +++ b/tests/Feature/Jobs/PublishToSocialPlatformTest.php @@ -20,6 +20,7 @@ use App\Models\Workspace; use App\Services\Social\ConnectionVerifier; use App\Services\Social\LinkedInPublisher; +use Carbon\Carbon; use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Mail; @@ -170,6 +171,125 @@ Bus::assertDispatched(PublishToSocialPlatform::class); }); +test('publish reschedules retry exactly 10 minutes into the future', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $now = now()->startOfMinute(); + Carbon::setTestNow($now); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn 503', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + + // error_context tracks the next attempt — must be exactly +10 min + expect($this->postPlatform->error_context['next_attempt_at'] ?? null) + ->toBe($now->copy()->addMinutes(10)->toIso8601String()); + + // The actual dispatched job carries the same delay + Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) use ($now) { + // $job->delay is a Carbon|DateInterval|int set by ->delay(...) + $delayAt = $job->delay instanceof DateTimeInterface + ? Carbon::instance($job->delay) + : null; + + return $delayAt !== null + && $delayAt->equalTo($now->copy()->addMinutes(10)); + }); + + Carbon::setTestNow(); +}); + +test('publish records last_attempt_at when rescheduling for retry', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + $now = now()->startOfMinute(); + Carbon::setTestNow($now); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn 503', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + + expect($this->postPlatform->error_context['last_attempt_at'] ?? null) + ->toBe($now->toIso8601String()); + + Carbon::setTestNow(); +}); + +test('post stays in Publishing while one platform is still Retrying', function () { + Bus::fake([PublishToSocialPlatform::class]); + Event::fake(); + Mail::fake(); + + // Second LinkedIn account on the same post — first one will publish OK, + // second one will hit PlatformUnavailable and reschedule. + $secondAccount = SocialAccount::factory()->linkedin()->create(['workspace_id' => $this->workspace->id]); + $secondPlatform = PostPlatform::factory()->linkedin()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $secondAccount->id, + 'enabled' => true, + 'status' => PlatformStatus::Published, // simulate already published + 'platform_post_id' => 'sibling-123', + ]); + + // Start the post as Publishing so updatePostStatus sees the in-flight context + $this->post->update(['status' => PostStatus::Publishing]); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andThrow( + new PlatformUnavailableException('LinkedIn 503', 503) + ); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + $this->post->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying); + // Post is NOT finalized because one of its platforms is still pending retry. + expect($this->post->status)->toBe(PostStatus::Publishing); +}); + +test('successful publish after a retry transitions the platform to Published', function () { + // Pre-condition: this platform already failed once and is currently Retrying. + $this->postPlatform->update([ + 'status' => PlatformStatus::Retrying, + 'error_context' => ['retry_count' => 3, 'category' => 'platform_unavailable'], + ]); + + Event::fake(); + + $publisher = Mockery::mock(LinkedInPublisher::class); + $publisher->shouldReceive('publish')->andReturn([ + 'id' => 'post-after-retry', + 'url' => 'https://linkedin.com/post/after-retry', + ]); + $this->app->instance(LinkedInPublisher::class, $publisher); + + (new PublishToSocialPlatform($this->postPlatform))->handle(); + + $this->postPlatform->refresh(); + + expect($this->postPlatform->status)->toBe(PlatformStatus::Published); + expect($this->postPlatform->platform_post_id)->toBe('post-after-retry'); +}); + test('publish retry count increments across successive platform_unavailable attempts', function () { Bus::fake([PublishToSocialPlatform::class]); Event::fake(); From c7309627abf297f2870e943d84046f4989d643d1 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 16:18:50 +0200 Subject: [PATCH 40/65] Refactor account deletion condition in ProfileController --- app/Http/Controllers/App/Settings/ProfileController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/App/Settings/ProfileController.php b/app/Http/Controllers/App/Settings/ProfileController.php index 0d7273be..69d6a3d6 100644 --- a/app/Http/Controllers/App/Settings/ProfileController.php +++ b/app/Http/Controllers/App/Settings/ProfileController.php @@ -126,8 +126,8 @@ public function destroy(ProfileDeleteRequest $request): RedirectResponse $user->workspaces()->detach(); - if ($account) { - $account->delete(); + if ($account && $user->isAccountOwner()) { + $account->delete(); } }); From 16d69e433841c23a945b8f2aa3039d0408ac70f1 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 16:37:05 +0200 Subject: [PATCH 41/65] Refactor storage method for asset uploads --- app/Http/Controllers/App/AssetController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Http/Controllers/App/AssetController.php b/app/Http/Controllers/App/AssetController.php index 0a89a4fb..eab0588d 100644 --- a/app/Http/Controllers/App/AssetController.php +++ b/app/Http/Controllers/App/AssetController.php @@ -145,7 +145,7 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService $filename = Str::uuid().'.'.$extension; $path = 'medias/'.$filename; - Storage::disk('public')->put($path, $response->body()); + Storage::put($path, $response->body()); $meta = []; $tempFile = tempnam(sys_get_temp_dir(), 'unsplash'); From 7765640fdd736b66a976aa539058f13d4afca922 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 16:41:40 +0200 Subject: [PATCH 42/65] Refactor media storage method to use default disk --- app/Models/Traits/HasMedia.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index 7fbb8590..5b9f1f52 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -88,7 +88,7 @@ public function addMedia(UploadedFile $file, string $collection = 'default', arr $filename = Str::uuid().'.'.$normalizedExt; $path = 'medias/'.$filename; - Storage::disk('public')->put($path, $normalizedBytes); + Storage::put($path, $normalizedBytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), From 9bc46996e4de023bcf96b2c20fe5d990964c2008 Mon Sep 17 00:00:00 2001 From: Sebastian Albert <60854302+albertvisuals@users.noreply.github.com> Date: Tue, 19 May 2026 16:42:25 +0200 Subject: [PATCH 43/65] Refactor storage method for media upload --- app/Models/Traits/HasMedia.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Models/Traits/HasMedia.php b/app/Models/Traits/HasMedia.php index 5b9f1f52..c1b9cb5e 100644 --- a/app/Models/Traits/HasMedia.php +++ b/app/Models/Traits/HasMedia.php @@ -126,7 +126,7 @@ public function addMediaFromPath(string $filePath, string $originalFilename, str $filename = Str::uuid().'.'.$normalizedExt; $storagePath = 'medias/'.$filename; - Storage::disk('public')->put($storagePath, $normalizedBytes); + Storage::put($storagePath, $normalizedBytes); return $this->media()->create([ 'group_id' => $groupId ?? Str::uuid()->toString(), From 74bd88d3d63ea6de1eb996032666324533034f39 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Tue, 19 May 2026 11:45:16 -0300 Subject: [PATCH 44/65] feat(auth): self-hosted registration gate + admin seeder (closes #46) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-hosted installs (SELF_HOSTED=true, the default) now close /register to the public. Workspace invites still work — the AcceptInvite page links into /register with ?invite={id}, the middleware persists that into the session, and POST /register passes through. - EnsureRegistrationEnabled middleware gates GET/POST /register. Accepts ?invite=… (URL) or pending_invite_id (session) as the pass. - RegisteredUserController::store clears the marker after signup. - AcceptInvite.vue passes invite.id in the register link's query string. - Login.vue hides the "Sign up" link when self_hosted. - UserSeeder bootstraps a single admin (admin@trypost.it / password). Idempotent; not wired into DatabaseSeeder — operator runs `php artisan db:seed --class=UserSeeder` per the install docs. - Tests cover both flag values for every changed surface. Docs PR: see trypost-docs self-hosting/installation.mdx step 3. --- .../Auth/RegisteredUserController.php | 2 + .../App/EnsureRegistrationEnabled.php | 27 +++++++ bootstrap/app.php | 2 + database/seeders/UserSeeder.php | 36 +++++++++ resources/js/pages/auth/AcceptInvite.vue | 2 +- resources/js/pages/auth/Login.vue | 8 +- routes/auth.php | 6 +- tests/Feature/AcceptInviteControllerTest.php | 24 +++++- tests/Feature/Auth/RegistrationTest.php | 73 +++++++++++++++++++ tests/Feature/Auth/SignupUtmTrackingTest.php | 2 + tests/Feature/GitHubAuthToggleTest.php | 15 ++++ tests/Feature/GoogleAuthToggleTest.php | 15 ++++ 12 files changed, 206 insertions(+), 6 deletions(-) create mode 100644 app/Http/Middleware/App/EnsureRegistrationEnabled.php create mode 100644 database/seeders/UserSeeder.php diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index b030065a..dd13f0f5 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -57,6 +57,8 @@ public function store(Request $request): RedirectResponse Auth::login($user); + $request->session()->forget('pending_invite_id'); + if ($redirect = $request->input('redirect')) { if (str_starts_with($redirect, '/') && ! str_starts_with($redirect, '//')) { return redirect($redirect); diff --git a/app/Http/Middleware/App/EnsureRegistrationEnabled.php b/app/Http/Middleware/App/EnsureRegistrationEnabled.php new file mode 100644 index 00000000..82d04c4c --- /dev/null +++ b/app/Http/Middleware/App/EnsureRegistrationEnabled.php @@ -0,0 +1,27 @@ +query('invite') ?? $request->session()->get('pending_invite_id')) { + $request->session()->put('pending_invite_id', $inviteId); + + return $next($request); + } + + throw new NotFoundHttpException; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 12e6c57c..03691e8c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ alias([ 'workspace.token' => LoadWorkspaceFromToken::class, + 'registration.enabled' => EnsureRegistrationEnabled::class, ]); $middleware->preventRequestForgery(except: [ diff --git a/database/seeders/UserSeeder.php b/database/seeders/UserSeeder.php new file mode 100644 index 00000000..a3a28b5d --- /dev/null +++ b/database/seeders/UserSeeder.php @@ -0,0 +1,36 @@ +exists()) { + $this->command->info('UserSeeder: a user already exists, skipping.'); + + return; + } + + $user = CreateUser::execute([ + 'name' => 'Admin', + 'email' => 'admin@trypost.it', + 'password' => 'password', + 'email_verified_at' => now(), + 'timezone' => 'UTC', + ]); + + CreateWorkspace::execute($user, ['name' => 'My Workspace']); + + $this->command->info('Admin account created — change the password on first login:'); + $this->command->line(' email: admin@trypost.it'); + $this->command->line(' password: password'); + } +} diff --git a/resources/js/pages/auth/AcceptInvite.vue b/resources/js/pages/auth/AcceptInvite.vue index 278ee462..d168ef32 100644 --- a/resources/js/pages/auth/AcceptInvite.vue +++ b/resources/js/pages/auth/AcceptInvite.vue @@ -96,7 +96,7 @@ const inviteUrl = computed(() => `/invites/${props.invite.id}`); diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 6a3376f7..3b25e1d9 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -1,5 +1,6 @@