Resolves the AiPostWizard conflict and completes the feature:
- i18n parity: brand_colors_label + brand_colors_description in all 15 locales
(was en/es/pt-BR only, which broke LocalizationParityTest).
- Reworked the two-button toggle into a Switch with an explanatory description
(matches the settings Switch/card pattern).
- Only shown for templates that honor the flag: added appliesBrandVisuals() to
the AiContentTemplate contract (ImageCard=true, tweet cards=false), exposed as
applies_brand_visuals in the create-page DTO, and gated the toggle on it — so
it no longer appears (as a no-op) for tweet-card styles.
- Tests: TemplateContractTest covers appliesBrandVisuals for all templates.
The node builder is gated to lg+ (its fixed side panels no longer overflow a phone); below lg it shows a 'works best on a larger screen' notice. Automation tabs scroll instead of cutting off.
- Top switcher (Compose / Channels / Preview / Comments) reveals the panel that was hidden below lg
- Sticky bottom action bar puts schedule/publish/delete within thumb reach
- Composer media actions and comment toolbar are visible on touch (no hover needed); media grid is 3-up
- Clamp media picker and emoji picker widths to the viewport
Replace the move-image-behind-a-fixed-frame model with a selection box over the fully visible (contain-fit) image: drag to move, corner handles to resize, locked to 1:1 for the square avatar/logo output. The default selection is inset so the crop outline is always visible, handles sit inside the frame so they are never clipped, and wheel/pinch zoom is swallowed. Reset drag state on open/close, guard pointer capture and multi-touch, and fail-safe the canvas encode. Update the crop copy in all 15 locales to match the selection model.
- Output a canvas-encodable mime (jpeg/png/webp, else png) and keep the File's
name/extension in sync, so non-encodable input (gif/svg/heic) no longer ships
PNG bytes mislabeled as the original type.
- Clamp zoom to a maximum (8x cover) so scrolling in can't collapse the crop to
a sub-pixel region.
- Handle undecodable/zero-dimension images (@error + naturalWidth guard) with a
crop_error message instead of a permanently-disabled Save.
- Replace the str_contains(static::class) Vite heuristic with a dedicated
BrowserTestCase ($fakesVite = false).
- The browser test now decodes the dispatched blob and asserts a 512x512 image,
and uses route(..., absolute: false) instead of a hardcoded path.
- Remove tests/Browser/ProbeTest.php (committed debug scratch).
Selecting an avatar or workspace logo now opens a crop dialog (drag + zoom)
before uploading, so the image is framed the way it renders. The crop is
performed client-side and the resized 512x512 result is what gets uploaded.
This reworks the idea from #131 without its cropper dependency: vue-advanced-cropper
was last released ~2 years ago and we did not want an unmaintained package for
something this load-bearing. What we need is narrow (fixed 1:1, a circle/square
mask, fixed-size output), so a small canvas-based cropper covers it:
- imageCrop.ts: pure transform math (cover-fit, clamp, zoom, viewport->source).
- ImageCropperDialog.vue: CSS-transform preview, pointer drag, wheel/button zoom,
a ResizeObserver to measure the modal (no requestAnimationFrame timing hacks),
and a canvas toBlob only on save.
- PhotoUpload.vue: opens the cropper on file select; the mask shape follows the
display shape (round avatar / square logo) instead of always being round.
- crop_* strings added to all 15 locales.
Also installs Pest browser testing (pest-plugin-browser + Playwright) and adds a
browser test for the crop flow. TestCase only calls withoutVite() for non-browser
tests, since browser tests need the real Vite assets to boot the SPA. The Pest
browser server does not parse multipart uploads, so the test asserts the crop
dispatches the correct upload request; endpoint persistence stays covered by
ProfileUpdateTest.
- Brand-analyzer prompt now lists every supported language instead of only
en/pt-BR/es, so onboarding autofill can detect the 12 added languages. The
backtick-formatted list is built in BrandAnalyzer::instructions(), keeping the
Blade clean and the enum free of prompt presentation.
- Translate the delete-confirmation keyword for el/ja/zh/ar (the four locales
that still shipped the English "delete").
- Make the language and font comboboxes RTL-correct (logical ms-* instead of
physical ml-*), and let the language combobox be searched by English name via
a visually-hidden label (ContentLanguage::options() now exposes englishName).
- Correct the ContentLanguage class docblock: the enum is also the source of
truth for the UI locales' text direction.
Tests: SetLocale middleware dir/RTL, isRtl and the full 15-language
englishName/label match arms, LLM language detection beyond en/es/pt-BR, and
store-path persistence of a non-default content language plus rejection of an
unsupported one.
Move the nav badge from the physical right-2 to the logical end-2 so it
sits at the end of the row in both directions (right in LTR, left in RTL).
Replace the hardcoded 'Beta' string with a global common.beta translation
key across all 15 locales.
Replace the plain content-language <Select> in the brand form with a
searchable LanguagePicker combobox (Popover + Command), matching the
FontPicker. i18n the combobox placeholder/search/empty strings across all
15 locales.
Switch the UI language via a full page reload instead of client-side dir
syncing, so the server-rendered <html dir> flips LTR<->RTL correctly
without a manual refresh.
Add full lang/ translations for fr, de, it, nl, pl, el, ja, ko, zh, ru,
tr, and ar — all 23 base files each, with identical key trees to lang/en,
preserved :placeholders and plural forms, and native product terminology.
The image pipeline already threads `applyBrandVisuals` through
`TemplateContext` -> `PostImagePipeline` -> `TemplateImageGenerator`, but it was
hardcoded to `true` at the dispatch site, so generated images always used the
workspace brand palette with no way to opt out.
Expose the choice in the create wizard: a "Brand colors" / "Let AI decide"
toggle (shown only when images are generated). The flag flows
front -> `StartPostCreationRequest` (`apply_brand_visuals`) ->
`PostAiCreateController@start` -> `StreamPostCreation` -> `TemplateContext`,
defaulting to `true` so existing behavior is unchanged when the field is absent.
- add an end-to-end walk (account gate -> persona -> goals -> connect ->
Stripe) and a self-hosted bypass test, plus the connect no-workspace and
just-exploring-saved cases
- drop the just_exploring exclusivity: the backend now saves any valid goal
combination (the front-end still clears siblings as a UX nicety), removing
the withValidator rule, the unused Goal::isExclusive(), and the now-unused
goals_exclusive copy
After picking who they are, users now pick what they want to achieve with
TryPost. A multi-select goal step (12 options + an exclusive "just exploring"
and "something else") sits between the persona step and connect, mirroring the
persona screen's style.
The goals persist to a json column on users and are mirrored to PostHog on
identify (onboarding_goals array plus a boolean per goal), so campaigns can be
cross-tabbed against the intent they actually attracted. connect now requires
both a persona and at least one goal; persona store advances to the goal step.
Options are grounded in TryPost's real capabilities (publishing, AI content,
brand voice, automation via API/MCP, collaboration, analytics) and copy is
localized in en/es/pt-BR.
Pinterest video pins failed with "provide cover_image_url ... or
cover_image_key_frame_time" because no cover was sent. Default to
cover_image_key_frame_time 0 (first frame) when the user provides no
cover_image_url — verified against Pinterest's OpenAPI spec (integer
seconds, minimum 0, falls back to the last frame past the duration).
YouTube Shorts derive their required title from the post content, so the
publisher rejects an empty post — but the editor still let it be scheduled.
Gate it in compliance: a youtube_short with empty content is now flagged
(requires_text) so it can't be scheduled, instead of failing at publish.
Connect flow:
- surface OAuth failures: useOAuthPopup forwards {success, message}; the
accounts grid toasts the error instead of silently reloading
- detect a blocked popup and toast a "allow popups" message
- PopupCallback only promises auto-close when it has an opener, otherwise
tells the user to close it; adds role=status/aria-live and aria-hidden icons
- drop the dead LinkedInPage connect-route entry and the unsent access_token
field on the Facebook page-select type
Media/gallery:
- delete dead code: MediaDropzone.vue and useDragAndDrop.ts (no references)
- GalleryBrowser toasts per-file upload failures instead of swallowing them;
cards are always clickable now that videos open in the lightbox
- formatDurationMs rounds the sub-second branch and tolerates undefined
- advertise PDF in the gallery's accepted-formats hint
i18n: popup_blocked, manual_close (accounts) and upload.failed (assets) in all locales.
Replace the per-platform native form POST + manual CSRF + JSON/Blade
popup callback with a single Inertia mechanism:
- popupCallback() renders the accounts/PopupCallback Inertia page (notifies
the opener + closes the popup) for both the GET OAuth callbacks and the
selection submits. Drops the auth.social-callback Blade view, the
expectsJson JSON branch, and useHttp/useSocialConnect on the frontend.
- Selection/credential pages (LinkedIn, Facebook, Instagram, Bluesky,
Mastodon) use Inertia useForm: automatic CSRF + native validation errors.
- Unify the three selection screens on one row + View/Choose layout; the
LinkedIn company tag now uses a building icon.
- Bluesky auth failures throw ValidationException (422 for XHR, redirect
back with errors otherwise).
Controller tests updated from assertViewIs/assertViewHas to assertInertia.
Selecting media in the gallery dropped original_filename, size, and meta when building the picked item, so they never reached the post. Since the gallery is the only way to add media, this left PDF documents without a filename (empty title field, 'PDF' thumbnail label, 'Document' fallback on publish) and skipped size/duration validation for all gallery media. Carry those fields through (guarded for unsplash/giphy items that lack them).
LinkedIn settings module: render only when a PDF is attached (its only relevant config now that format is inferred from media), pre-fill the document title from the PDF filename (editable), drop the redundant media warning (already surfaced on the channel) and the unused contentType prop/binding.
LinkedInSelect: add Person/Organization tag icons. i18n: clearer document-not-alone message in all locales.
Collapse LinkedIn to one content type per account kind (linkedin_post, linkedin_page_post). Publishers infer the publish format from the attached media — text, single image/video, multi-image carousel, or PDF document — matching how facebook_post/x_post already work; PDF is exclusive of any other attachment. Removes the editor variant picker, keeping only the PDF document title field. Includes a data migration collapsing the retired carousel/document content types.
Replace the two LinkedIn account cards with a single Connect LinkedIn button: one unified OAuth grant (linkedin-openid driver, union of scopes) then a post-callback identity picker to post as the personal profile (linkedin) or a company page the member administers (linkedin-page). The chosen organization is validated against the admin-verified list from the OAuth grant. Per-capability gating via LINKEDIN_ENABLED / LINKEDIN_PAGE_ENABLED supports profile-only or org-only self-hosting. Removes LinkedInPageController, LinkedInTokenSynchronizer, the standalone linkedin-page connect routes, and the unused redirect_page config.
Review follow-ups before QA:
- Editor: getMediaIncompatibilityReason rejects a PDF on non-document content types, so the schedule gate and variant auto-snap match the backend rule (compliance i18n in en/es/pt-BR)
- MCP UpdatePostTool: validate effective content_type vs stored media on schedule, closing the schedule-without-content_type gap; share entriesForUpdate/errorsFor with the API path
- Tests: Platform allowedMediaTypes contains Document, URL-attach of a PDF (LinkedIn ok / TikTok rejected), multi-platform PDF rejection, document init-failure/missing-URN, Page publisher PROCESSING_FAILED
Add LinkedIn document posts — the swipeable PDF carousel — for both personal profiles and company pages. This is the format every major competitor exposes via native PDF upload, and the reason a trial user churned.
- New 'document' media type (application/pdf) across the upload pipeline (Type enum, HasMedia, FormRequests incl. chunked, Platform media types)
- New LinkedInDocument / LinkedInPageDocument content types: PDF-only, single-file, with a supportsDocument() flag
- Publisher flow: documents initializeUpload -> PUT -> poll AVAILABLE -> post with content.media.{id,title}; optional document_title meta (falls back to file name)
- PDF is mutually exclusive with image/video, enforced in ContentTypeCompatibleWithMedia
- Frontend: 'Document (PDF)' variant, media rules (100MB cap), composer/gallery/detail PDF cards, real PDF embed in the LinkedIn editor preview, i18n in en/es/pt-BR
- Tests: publishers (personal + page, incl. processing-failure path), enums, compatibility rule, chunked PDF upload, API + MCP document_title round-trip
LinkedIn caps documents at 100MB / 300 pages (Documents API). The page limit is enforced by LinkedIn at publish, not validated client-side.
The persona options mix individual roles (creator, developer, marketer) with
organization types (startup, agency, store), but the prompt only asked what
describes 'you'. Make it read intentionally:
- Broaden the prompt to 'you or your business'.
- Creator -> Content creator and Marketer -> a proper role label (es/pt:
Profesional/Profissional de marketing) so the role group is consistent.
Completes the persona grid to a 3x3 with two high-value segments:
Marketer (in-house marketing / social media, a scheduling tool's core user)
and Online store (e-commerce). Labels in all three locales, with
IconSpeakerphone and IconShoppingBag glyphs. Other stays last.
Adds a Developer option to the onboarding persona step (after Freelancer),
with labels in all three locales (Developer / Desarrollador / Desenvolvedor)
and an IconCode glyph in the persona grid. The option flows to the frontend
via Persona::cases() and is accepted by the existing enum validation.
A Bluesky post embed is images XOR video, so a post that carries both can't
be published there. Enforce it per selected platform:
- Add ContentType::supportsMixedMedia() (false only for BlueskyPost) and reject
image+video in ContentTypeCompatibleWithMedia when the type forbids it.
- Mirror it client-side (useMediaRules forbidsMixedMedia + usePostCompliance)
so the editor blocks scheduling with an inline reason before submit, like
every other per-platform compatibility check.
- Add the no_mixed_media message in all three locales (en/es/pt-BR).
- Cover the rule and enum, including the GIF-counts-as-image case.
- store(): members without connected accounts no longer get redirected into
the now-admin-only /accounts (403); non-managers go to the calendar with
the same flash, admins still go to /accounts
- cover the SyncPostPlatforms can('update') gate (viewer creates no platform
rows; member does) and the store redirect split, in WorkspaceRolePermissions
- cover PostPolicy::duplicate viewer-denied
- docs sidebar link uses the canonical https://docs.trypost.it
- drop orphaned sidebar.support.{discord,last_updates} keys in all locales
- remove the explanatory isLocked comment in Edit.vue
Add a Support group at the bottom of the sidebar with Share feedback,
Earn 30% referral (affiliates), Stay updated (X) and Documentation.
Share feedback and Documentation move out of the user menu into this
section. Adds the referral/stay_updated labels in all three locales.
AcceptInviteController attached invited users with a hardcoded member role,
ignoring the invite's role entirely (a viewer invite joined as member). Use the
invite's role on accept, require role on invite creation (no silent default),
and drop the role from the request/CreateInvite defaults. Add the Viewer option
to the member role dropdown (now iterates all roles), hide the dropdown on the
current user's own row, and require the member's email to confirm removal.
Covered by tests asserting the accepted role matches the invited role for
viewer/admin/member, plus invite role validation.
Changing the language re-renders the UI in the new locale already, so the
'Language updated' banner was redundant. Remove the flash and its orphaned
i18n key.
Send the user's persona on both the server-side subscription.created event and
the client-side checkout.completed/dataLayer purchase event, for ICP analysis.
Hold the processing screen ~5s before redirecting so PostHog and the ad pixels
(Google/Meta via GTM) reliably flush. Sharpen the annual-upgrade banner copy
(lead with '2 months free') and give its check icon a white tile.
Emphasize Google/GitHub on the register page and hide the email form behind a
'Sign up with email' toggle that reveals it (shown directly when no social
providers are configured). Even spacing across all three buttons, an optional
hideDivider prop on SocialLogin, and a stronger value-led headline/subtext.
Add a connect step between persona selection and Stripe checkout: persona ->
connect >=1 network (server-enforced) -> checkout. Extract the network grid into
a shared NetworkConnectGrid used by both onboarding and the accounts page, which
is redesigned from a table into the same grid (one account per network, with
per-card connect / connected+disconnect / reconnect states). Drop the openDialog
auto-open flow now that networks are shown inline.
When a Telegram channel connection is rejected (e.g. the workspace already has a
channel for that network), broadcast TelegramConnectFailed so the connect dialog
shows a clear error message instead of waiting until the code silently expires.
Bug fix
- Surface the localized "network already connected" message on the
Facebook/Instagram/InstagramFacebook/LinkedInPage/Threads/YouTube OAuth
callbacks: catch NetworkAlreadyConnectedException before the generic catch
so the conflict no longer falls through to a generic error + Log::error.
Scope / dead code
- Remove the orphaned BillingController::checkout() + app.billing.checkout route
(onboarding starts checkout directly); delete the now-dead DiscordWidget and
useFeatureAccess composable; prune orphaned i18n keys left by removing the
plan picker / upgrade dialog / count limits (billing.subscribe.*,
accounts.limit_reached, workspaces.limit_reached, common.discord.*).
- Drop the unused `plan` prop from the usage page and the unused `label` from
the onboarding persona payload (labels come from i18n); remove
Persona::options()/label().
Conventions
- declare(strict_types=1) on the two new migrations.
- Extract autofill validation into AutofillBrandRequest (FormRequest).
- Drop the unused $plan param from AccountPolicy::swapPlan.
- CreateUser: drop the stale config('cashier.trial_days', 7) fallback (now 8).
- Rename LimitEnforcementTest to InvitePermissionTest; use Pest mock() helper in
WorkspaceQuantitySyncTest; move shared test helpers into Pest.php.
- Memoize BillingCycle window() + subscription lookup.
Pricing
- Bill per workspace ($12/mo or $120/yr each); Stripe quantity tracks the
workspace count and syncs on workspace create/delete.
- 2,500 AI credits per workspace, pooled at the account level; monthly reset
on the billing anniversary, annual granted upfront (no rollover).
- One social account per network per workspace; remove all count-based limits
(workspace/social/member) and the legacy plan tiers (single Workspace plan).
Onboarding (cloud only: SELF_HOSTED=false + PostHog)
- Replace the /subscribe plan picker with /onboarding persona selection
(Creator/Freelancer/Startup/Agency/Small business/Other), saved on the user
(users.persona) and mirrored to PostHog, then Stripe Checkout on the monthly
price. 8-day trial so Stripe displays 7.
Billing screen
- Remove the Change Plan dialog (dead with a single plan); add an annual-upgrade
banner for monthly subscribers (swapToYearly).
- Current-plan card shows the workspace count instead of the plan name.
System AI
- Brand analyzer / workspace autofill is always allowed and never debits credits
(system feature, not the user's usage).
Self-hosted (SELF_HOSTED=true) bypasses all billing, credit, limit, network,
and onboarding logic.
Add per-post Discord metrics (server member count, thread replies as comments,
and reaction counts) read live with the bot token via GET message + guild
with_counts, wired into PostMetricsFetcher. Redesign the shared post metrics
row: monochrome line icons for member/comment counts vs clean reaction pills,
so audience stats read distinctly from reactions. Applies to Telegram too.
- Introduced caching for Discord channel lookups, reducing API calls and improving performance.
- Updated the DiscordSettings component to use a SearchableSelect for better user experience when selecting channels.
- Added new language strings for channel search and no channels found messages in English, Spanish, and Portuguese.
- Updated tests to ensure channel list caching is isolated between runs.
Connect a Discord server via OAuth (bot authorization) and schedule/publish
messages to its channels, with mentions and rich embeds.
- Connect: custom Socialite Discord provider (bot scope) maps the authorized
guild to a SocialAccount; throws if no server was authorized.
- Publish: DiscordPublisher posts via the global bot token, validates the chosen
channel belongs to the connected guild (anti cross-guild), optimizes media,
builds allowed_mentions only from explicit mention chips (no accidental pings),
and renders rich embeds.
- Compose: per-post channel picker (live lookup), mention autocomplete and an
embed editor, gated by a required-channel compliance rule; Discord post preview.
- Enum/config/content-type wiring, ConnectionVerifier health check, throttled
lookup endpoints, i18n (en/es/pt-BR), and tests.
Operators must create a Discord application and set DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET, DISCORD_BOT_TOKEN and DISCORD_CLIENT_REDIRECT.
Replace the RSS-2.0-only SimpleXML parser with SimplePie so the Fetch RSS
node reads Atom 1.0 (YouTube, GitHub, The Verge…) and RSS 2.0 + namespace
extensions (dc:, content:, media:, yt:, itunes:). Each item exposes stable
cross-format aliases (title, link, date, content, author, …) plus every
namespaced field flattened for use as {{ fetched.* }}.
Add a feed-inspection endpoint that discovers a feed's real fields and feeds
them into the editor's expression autocomplete. Allow {{ }} expressions in the
feed URL via a ResolvableUrl rule. Parsing moves to a dedicated FeedParser
service; the fetch keeps the SSRF guard and gains XXE-safe parsing.