`$account->stripe()` can throw `\Stripe\Exception\InvalidArgumentException`
(descends from PHP's `InvalidArgumentException`, not `ApiErrorException`)
when the Stripe key is missing/malformed. Since the conversion data is
purely for tracking, any failure must degrade silently — not break the
post-payment success page.
Wire `value`, `currency`, and `transaction_id` from Stripe Checkout Session
into the `purchase` dataLayer event so GTM can fire Google Ads Conversion
Tracking with accurate per-plan revenue and deduped transaction IDs.
- `BillingController::checkout` adds `{CHECKOUT_SESSION_ID}` to success_url
- `BillingController::processing` retrieves session lazily (closure prop,
so polling partial reloads don't re-hit Stripe) and exposes
`conversion` with `value`/`currency`/`transaction_id`
- `Processing.vue` forwards `conversion` to `trackPurchase`
- `useTracking.trackPurchase` pushes `conversion_value`,
`conversion_currency`, `conversion_transaction_id` to dataLayer +
PostHog
The inline "Generate post with AI" dialog never showed any output, even
after the queue job finished. Three independent issues stacked:
1. Event name mismatch in useAiStream. Listeners were CamelCased
(.TextDelta, .StreamEnd, .Error) but laravel/ai broadcasts events
with snake_case names via StreamEvent::type() (text_delta, stream_end,
error). Echo never matched, the listener never fired, status stuck on
'streaming' forever. Renamed to snake_case.
2. The streamer agent shares the structured-output prompt template with
PostContentGenerator, so the stream emits a JSON object like
{"content":"...","image_title":"...","image_keywords":[...]}, not
plain caption text. Added a previewText computed in AiGenerateDialog
that JSON.parse's the accumulated text and grabs .content. Mid-stream
the parse fails, so the preview stays empty (showing the existing
'...' placeholder) until stream_end produces a complete JSON.
3. The Generate button used v-if="status === 'idle'" — it disappeared
the instant generation started, leaving the user with no feedback.
Swapped to v-if="status === 'idle' || status === 'streaming'" and
used the Button component's built-in :loading prop so it stays
visible with a spinner inside while the agent runs.
Two more pieces in app/Ai/ that were registered but never reached by any
production caller, same audit as the PlatformRules cleanup.
DebugGeminiRequest middleware: zero references — not registered in the
laravel/ai middleware pipeline, no agent imports it. Pure dev-time
scaffolding that got left behind.
ExtendedGeminiProvider: registered via Ai::extend('gemini', ...) so the
class IS resolvable when AI_DEFAULT=gemini, but its only override —
defaultImageOptions — is unreachable in practice:
- All text agents (PostContentGenerator, PostContentHumanizer,
PostContentReviewer, PostContentStreamer, BrandAnalyzer) only call
text generation, never defaultImageOptions.
- Image generation (AiImageClient) hardcodes OpenAI's `gpt-image-2`
model, so the Gemini provider isn't on the image path either.
Removed:
- app/Ai/Middleware/DebugGeminiRequest.php
- app/Ai/Providers/ExtendedGeminiProvider.php
- tests/Unit/Ai/Providers/ExtendedGeminiProviderTest.php
- Ai::extend('gemini', ...) wiring + configureAi() method and its four
imports (ExtendedGeminiProvider, Ai, GeminiGateway, Dispatcher) in
AppServiceProvider.
Left over from the PostEditorHeader extraction in #26 — the header now
derives its own isPublished from the post prop, and Edit.vue itself
doesn't use it anywhere. ESLint caught it on CI.
The whole app/Ai/PlatformRules/ system was registered in AppServiceProvider
but never read in production code. Confirmed via grep across app/, resources/,
config/, database/, tests/ — only consumers were:
- AppServiceProvider::configurePlatformRules() registering everything in the
Registry (which nothing then called)
- tests/Feature/Ai/PlatformRules/RegistryTest.php asserting the registrations
it had just performed
The actual AI text agent (PostContentGenerator) reads the per-platform caps
straight from the Platform enum (maxContentLength / recommendedAiContentLength)
and feeds them into the prompt template — bypassing this layer entirely.
Removed:
- app/Ai/PlatformRules/ (12 files: Contract, Registry, 10 Rule classes)
- tests/Feature/Ai/PlatformRules/RegistryTest.php
- 11 use imports + boot() call + configurePlatformRules() method in
AppServiceProvider
Scheduled posts were silently editable: auto-save fired on every keystroke
with status='scheduled', and the backend either silently failed (per-platform
content-length, our new rule) or accepted invalid changes that blew up at
publish time. Editing a queued post is also a race with the publisher job.
UX change: scheduled state now reads-only the editor and exposes one
explicit escape — Unschedule to edit. The post drops to draft, user edits,
re-schedules. Matches Buffer/Hootsuite/Later/Postiz.
Implementation:
- New isLocked computed (= isReadOnly || isScheduled) gates save(),
triggerAutosave(), debouncedSave(), togglePlatform(). submit() stays on
isReadOnly so unschedulePost('draft') can still transition out.
- Editor body wraps in pointer-events-none + opacity-60 when isScheduled,
visually dimming inputs without per-input :disabled plumbing.
- Header swaps to a violet "This post is scheduled / Unschedule to edit"
banner replacing the entire normal action bar. Delete is hidden in
this state — only escape is Unschedule, then act on the draft.
Refactor: extracted the entire header into PostEditorHeader.vue (matches
the PostEditorComposer / PostEditorSidebar family). Single <header> root
with v-if/v-else branches by status. ~85 lines out of Edit.vue.
Cleanup: dropped orphan posts.edit.scheduled_for translation key from
en/pt-BR/es — replaced by the new scheduled_overlay_* keys.
- Platform::contentOverflow returns int (0 = fits) via max(), drops the
nullable-int + ternary pattern; HasSocialHttpClient and the validation
rule updated to compare against 0 instead of null.
- Drop the hardcoded 63206-char limit to 10000 across all entry points
(Platform enum, MCP CreatePostTool/UpdatePostTool, FacebookRules, all
three FormRequests). Facebook's API accepts up to 63206 but nobody
writes 63k-char posts and emoji-heavy content risks overflowing the
TEXT column's 65535-byte ceiling.
- Compiled i18n JSON regenerated with the content_exceeds_platform key.
Threads posts over 500 chars were saved + scheduled successfully and only
failed inside the publish job. The frontend already showed the 537|500 badge
but `canSchedule` ignored content length, so Schedule and Post Now stayed
enabled. Backend `UpdatePostRequest` only capped at 63206 (Facebook's max),
not per-platform.
- Add `Platform::contentOverflow()` as the single source of truth and reuse it
from `HasSocialHttpClient::validateContentLength` (publish-time).
- New `ContentFitsPlatformLimits` rule applied to the `content` field on
`App\\UpdatePostRequest`, `Api\\UpdatePostRequest`, and `Api\\StorePostRequest`
via `Rule::when(...)` so drafts are not blocked.
- Rule dedupes per platform (two Threads accounts -> one error) and reports
the platform label, hard cap, and overage via i18n.
- Edit.vue feeds `contentLengthOverflows` into `canSchedule` and lists each
offending platform in `postActionTooltip` using the existing
`getPlatformLabel` resolver.
Two related fixes that together eliminate the 'Loading your TikTok
account settings…' flicker users were seeing on every keystroke /
variant click in the post editor:
1. PostController::edit no longer wraps tiktokCreatorInfos in
Inertia::defer. The map is computed during the initial render and
shipped as a regular prop. Without defer, the prop never resets to
null between Inertia visits, so the loading line never reappears.
2. TikTokCreatorInfo::fetch is now wrapped in a 5-minute Cache::remember
keyed by social_account_id. Autosaves (which round-trip through
PostController::update → back() → edit() again) used to issue a
fresh TikTok API call for every connected account on every save —
now the cache short-circuits them. Creator info changes very rarely
(only when the user updates privacy settings on TikTok itself), so
five minutes of staleness is acceptable; the worst case is a
slightly out-of-date privacy-options list that corrects on next
page load.
Frontend cleanup: dropped the creatorInfoLoading prop, the inline
loading <p>, and the now-orphaned posts.form.tiktok.creator_info_loading
i18n key in en/pt-BR/es. ScheduleTab no longer passes the prop.
Pinterest has the same UX bug: default content_type is pinterest_pin
(image-only, 1 file). If the user attaches a video, the tile would
silently disable; if they attach 2+ images, also disabled — even though
pinterest_video_pin and pinterest_carousel exist as alternatives.
List all three Pinterest variants so the tile's lenient compliance
check considers each, and togglePlatform snaps to whichever fits the
attached media on selection.
Instagram, Facebook, and LinkedIn intentionally stay out: their defaults
(feed/post) accept both images and videos, so there's no silent block.
Including them would mask real per-variant errors (e.g. Reel + image
incompatibility) — that's a user-visible mistake we want surfaced, not
hidden at the tile level. The map is documented to that effect.
Replaces two TikTok-specific code paths (one in platformIssues, one in
togglePlatform) with a single generic helper. PLATFORM_VARIANTS maps
each platform that exposes multiple content types via a variant picker
to the list of those content types; firstCompatibleVariant returns the
first variant that fits the current media (or null when none fits).
Both consumers — the tile-level compliance check and the on-select
content_type snap — now share the same helper. Adding another platform
later (Instagram already has multiple variants if its default ever
changes) only requires extending the map, no logic changes.
Net effect: -15 lines, no TikTok-specific branching in logic, only data.
Reverts the previous reactive watcher approach. Replaces it with two
small, declarative changes:
1. platformIssues for TikTok now checks whether ANY variant
(tiktok_video / tiktok_photo) accepts the current media. The tile
only stays blocked when neither variant fits (e.g. a GIF, mixed
media). This matches the platform's real capability — TikTok does
accept both videos and photos — and stops disabling the tile based
on the post-platform's stale default content_type.
2. togglePlatform, when SELECTING TikTok, switches the post-platform's
content_type to the variant that matches the first attached media
(tiktok_video for video, tiktok_photo for image). Runs only on
selection, not as a watcher — no implicit reactivity, no side
effects on subsequent media changes. The variant picker inside
TikTokSettings remains the user's manual override.
Reproducer fixed: new post → attach image → TikTok tile is now
clickable instead of greyed out with 'Only videos are allowed for
this format'. After clicking, content_type is tiktok_photo.
The TikTok platform tile was getting silently disabled when the user
attached images before selecting TikTok: a fresh post defaults TikTok
to tiktok_video, the compliance check rejects images for video posts,
and platformIssues marks the tile as un-clickable.
Watch the media ref. If the first attached item is an image and the
TikTok post-platform's content_type is still tiktok_video (or vice
versa for video media on tiktok_photo), flip the content_type to match.
The variant picker inside TikTokSettings can still override afterwards;
this only covers the gap between adding media and opening the panel.
Reproducer: new post → attach image → click TikTok tile → previously
disabled with 'Only videos are allowed for this format', now enabled
and content_type lands on tiktok_photo.
Removes the messages() override on UpdatePostRequest. The 4 messages it
defined were hardcoded English strings that bypassed Laravel's built-in
validation translations. With the override gone, errors flow through
lang/{locale}/validation.php which is already translated for en/pt-BR/es.
Trade-off: default messages reference the field path (e.g.
'platforms.0.content_type'). Acceptable here because the editor renders
errors inline next to each field rather than as a flat list.
- ContentType::description() now reads from posts.content_types.{value}.description
instead of hardcoded English. Adds the missing tiktok_photo entries in en/pt-BR/es
and syncs three Pinterest descriptions that had drifted between the i18n file and
the previously-hardcoded enum strings (the enum strings were the user-visible source).
- UpdatePostRequest::withValidator surfaces the privacy_required validation error
via posts.form.tiktok.privacy_required (added in en/pt-BR/es) instead of an
English string.
- Drops a verbose 3-line comment in withValidator that explained obvious code.
## Photo carousel support
- Adds `ContentType::TikTokPhoto` enum case (max 35 photos, 1:1 aspect,
supportsImage true, supportsVideo false) and JS mirror in content-type.ts.
- Variant pill picker (Video / Photo carousel) at the top of TikTokSettings,
mirroring the Instagram pattern. Wired through ScheduleTab to the parent
editor's existing update:platformContentType emit.
- i18n keys for variant_label / variant.video / variant.photo in en/pt-BR/es.
- Publisher: split buildPostInfo into buildVideoPostInfo (uses `title`,
TikTok cap 2200 chars) and buildPhotoPostInfo (uses `description`, cap
4000 chars; omits Duet/Stitch/AIGC since they don't apply). Removed the
no-longer-needed queryCreatorInfo() call from publishVideo/publishPhotos
— its only previous consumer (silent privacy_level fallback) is gone.
## UX Content Sharing API compliance
Per TikTok review feedback citing
https://developers.tiktok.com/doc/content-sharing-guidelines#required_ux_implementation_in_your_app
Point 1 — already satisfied (creator_info fetch + nickname display).
Point 2/4 — Music Usage Confirmation declaration is now always visible
in TikTokSettings; text changes between "Music Usage Confirmation" and
"Branded Content Policy and Music Usage Confirmation" based on toggle
state. Previously the entire `<p>` block was conditional on a brand
toggle being selected, hiding the baseline declaration.
Point 2b — privacy_level may not have a default. UI was already correct;
backend hardened: UpdatePostRequest now requires meta.privacy_level for
tiktok platforms when status is publishing/scheduled (via withValidator);
TikTokPublisher::resolveRequiredPrivacyLevel throws TikTokPublishException
(ContentPolicy category) when missing instead of silently falling back to
the creator's preferred level.
Point 2c — interaction settings now condition on content type:
- Photo posts hide Duet/Stitch (they don't apply per TikTok docs).
- Photo posts hide AIGC (also video-only).
- Video posts hide Auto Add Music (photos-only feature).
- Max-duration warning hidden when not a video post.
Source of truth is the user-selected contentType prop, not inferred
from media — ensures the UI reacts immediately to the variant pill.
Point 3a — publish button stays disabled when Disclose toggle is on
without a sub-selection (already the case via tiktokComplianceValid).
The disabled tooltip now uses the verbatim TikTok-required text "You
need to indicate if your content promotes yourself, a third party, or
both." instead of the generic "Some platform settings are incomplete..."
when the only blocker is TikTok disclosure incompleteness.
Point 3b — SELF_ONLY (Only me) privacy option is no longer filtered out
when Branded Content is checked. It is rendered disabled with a hover
tooltip "Branded content visibility cannot be set to private." plus a
persistent amber warning paragraph below the dropdown. When the user
toggles Branded Content while privacy is SELF_ONLY, the privacy clears
and a vue-sonner warning toast surfaces the change.
## Cross-cutting
- New `resources/js/enums/platform.ts` mirrors the PHP Platform enum,
used in Edit.vue (tiktokComplianceValid + tiktokDisclosureIncomplete)
and ScheduleTab.vue (all selected*Platforms computeds) to replace
string literal comparisons against `'tiktok'` / `'facebook'` / etc.
- PostPlatformFactory tiktok() state defaults meta.privacy_level to
SELF_ONLY so existing test fixtures keep passing under the new
publisher/FormRequest requirements.
## Tests
- New tests/Unit/Enums/PostPlatform/TikTokPhotoContentTypeTest.php
covering the new enum case (4 tests).
- TikTokPublisherTest: added "video uses title not description" and
"throws when meta.privacy_level missing" regression tests; renamed
two existing tests that depended on the removed silent fallback.
- New tests/Feature/UpdatePostRequestTest.php with 3 tests covering
the FormRequest's privacy_level enforcement (publish-rejected,
publish-passes, draft-allowed).
Full Pest suite: 1490 passed, 2 skipped (pre-existing).
TikTok's photo endpoint caps the 'title' field at 90 UTF-16 runes but
allows 'description' up to 4000. Today the publisher always sends the
caption in 'title', which would silently fail for any photo caption
longer than 90 chars. Split buildPostInfo into buildVideoPostInfo
(uses title) and buildPhotoPostInfo (uses description, omits Duet/
Stitch/AIGC since they don't apply to photos).
Adds the enum case TikTokPhoto = 'tiktok_photo' with maxMediaCount 35,
supportsImage true, supportsVideo false, 1:1 aspect ratio. The publisher
service already calls /post/publish/content/init/ correctly — this
enum case is the missing data layer that lets validation, UI, and
defaults treat photo posts as a first-class content type.
Replaces the vague "intro → development → conclusion" guidance in the
generator prompt with an explicit per-slide arc: open with a specific
real urgent problem, develop the idea while creating swipe motivation,
prove with concrete evidence (result/before-after/BTS/learning), and
close with a single specific next action. Includes a slide_count to
role-distribution table (2 through 6+ slides).
Adds a `role` enum field to each slide in the carousel JSON schema
(hook, development, proof, cta) so the structure is enforced via SDK
not just prompt text. Field is additive — handleCarousel still reads
only title/body/image_keywords, so consumers are unaffected.
- TemplateImageGenerator->render() now returns a typed array shape
{path: string, source_meta: array} instead of a one-off RenderedSlide
DTO. Single internal callsite, no need for a dedicated class.
- Drop the `?? 'en'` fallback on $workspace->content_language in 6
callsites: the column has a NOT NULL default of 'en' at the DB level,
so the null coalesce was dead code.
- WorkspaceFactory now seeds content_language, brand_tone, brand_font
and image_style explicitly so make() (no DB persist) produces a
complete model — DB defaults aren't applied until create().
Convert all 'foo.'.$bar style concatenations to "foo.{$bar}" interpolation
in event broadcast channels (PostCreated, PostDeleted, PostCommentCreated,
PostPlatformStatusUpdated, NotificationCreated) and the related event
tests.
Document the convention in CLAUDE.md so new code follows the pattern.
- Refactor ImagePreviewDialog to expose open(url, type) / openCollection(items, idx)
via defineExpose; drop external props/state. GalleryBrowser and
PostEditorComposer now call lightbox.open()/openCollection() instead of
managing previewItem/previewIndex locally.
- Lightbox closes when clicking the empty space inside the wrapper (between
image and arrows) using @click.self. Image click also closes; video click
triggers play/pause without bubbling to close. Arrow buttons keep
@click.stop so navigation never closes.
- Assets gallery (uploads / Unsplash / Giphy) now opens the lightbox in
collection mode so the user can navigate the full tab list with arrows
or keyboard.
- Add 'delete' typed-confirmation on the post-delete dialog (Index +
Edit pages) and on the asset-delete dialog using the new
common.confirm_modal.delete_keyword translation (en: 'delete',
pt-BR: 'deletar', es: 'eliminar').
Core changes:
- Replace Unsplash slide pipeline with gpt-image-2 via Laravel AI SDK.
New AiImageClient builds prompts from a Blade template seeded by the
workspace's ImageStyle enum, content language, brand color (mapped to a
human-readable name via HexColorName helper) and brand description.
- Drop Template B from TemplateImageGenerator: every slide now renders as
Template A (full-bleed photo + bottom gradient + white/grey overlay).
Removes renderTemplateB, roundCorners, blendHex, ensureContrast and the
closing-slide pipeline.
- StreamPostCreation creates the Post directly and dispatches
PostCreationReady with post_id; the wizard kills its preview step and
redirects straight to the post editor on completion. Finalize endpoint
removed.
- New Workspace.image_style enum field with an 8-option visual picker
shared by /workspaces/create and /settings/workspace/brand via a single
BrandForm component (autofill is a prop). 8 sample webp thumbs ship
under public/images/branding/image-styles/.
- Media items gain optional source ('ai'|'unsplash'|'giphy') and
source_meta (recipe needed to regenerate AI images later); the gallery
picker tags Unsplash/Giphy attachments.
- Brand-color autofill: new CssColorFrequencyExtractor parses every
hex/rgb/hsl value in the homepage CSS, clusters perceptually similar
shades in CIE LAB (Delta E 76 < 12), filters neutrals and returns the
most frequent cluster. Solves Tailwind/utility-CSS sites where no
semantic --primary variable is exposed.
- Credits: gpt-image-2 metered at 15 credits/image (low quality default).
- Layout: AuthSplitLayout right column is sticky/h-svh so the form
textarea growth no longer stretches the marketing slider.
- i18n cleanup: localized labels follow the no-em-dash convention.
Class names and method signatures already explain what these classes
do. Project guideline (CLAUDE.md): comments only when the WHY is
non-obvious — implementation details belong in commits, not noise on
top of every class. Kept the one comment that earns it: the (int)
cast in HasUsage::cachedPostCount, which documents the load-bearing
workaround for Laravel's Redis cache numeric optimisation.
Two new composables under resources/js/composables/echo/ that abstract
channel naming for the two Reverb subscription patterns we now use:
- useWorkspaceEcho — list/dashboard views: 1 subscription per workspace
delivers events for many entities. Resolves currentWorkspace.id from
Inertia shared props; no-ops when there is no workspace yet.
- usePostEcho — focused views (Show, Edit): per-post channel.
Posts index now subscribes via useWorkspaceEcho to the 3 lifecycle
events (post.created / post.deleted / post.platform.status.updated)
and reloads the posts prop on receipt. `reset: ['posts']` is required
on the reload — without it, InfiniteScroll's merge prop appends every
broadcast onto the existing list and produces duplicates.
Show / Edit migrated from raw useEcho to usePostEcho; AiGenerateDialog,
NotificationBell and AiPostWizard updated to the new event names from
the namespaced convention. AiStream composable moved into the same
echo/ folder for organisation; its bound event names (TextDelta /
StreamEnd / Error) come from the laravel/ai package and stay as-is.
Bonus: comments tab send button was overriding shadcn's size="icon"
(40px) with class="size-9" (36px), which left it visibly shorter than
the textarea. Removed the override and matched the textarea's
min-h to size-10 so both render at 40px.
The event was dispatched by StripeEventListener but had no consumers
on either side (no Event::listen registration in PHP, no Echo listener
in JS, no subscriber on the users.{owner_id} channel). Removed along
with its tests; the listener no longer references it. Cleans up the
StripeEventListenerTest to match (drops Event::fake(SubscriptionCreated)
in five negative-path tests, replaces with Bus::assertNotDispatched(
TrackBilling) which is the actual cascade-side-effect we care about).
Adds a private workspace channel (WorkspaceChannel, authorised by
membership) so list views can subscribe once and receive events for
every post in the workspace, instead of opening N per-post channels.
New events:
- PostCreated (broadcast on workspace.{id})
- PostDeleted (broadcast on workspace.{id}; carries primitive ids so
it fires after \$post->delete())
- PostPlatformStatusUpdated now broadcasts on both post.{id} and
workspace.{id} so focused views and lists share the same trigger.
All broadcast events migrated to the namespaced 'entity.action'
convention from Laravel's broadcasting docs and switched from
ShouldBroadcastNow to ShouldBroadcast on a dedicated 'broadcasts'
queue (added to the supervisor-1 list in config/horizon.php) to keep
HTTP responses fast:
PostCreated -> post.created
PostDeleted -> post.deleted
PostPlatformStatusUpdated -> post.platform.status.updated
PostCommentCreated -> post.comment.created
NotificationCreated -> notification.created
PostCreationReady -> ai.creation.completed
Drops the SubscriptionCreated event — it was broadcast on
users.{owner_id} but had no listeners (frontend or backend) and the
billing/PostHog flow already handles plan-change tracking elsewhere.
PostPlatformStatusUpdated payload trimmed to {post_id} since every
consumer (Show, Edit, Index) does router.reload({ only: [...] }) and
ignored the rich shape.
Production crashed on every Inertia request after the PostHog branch
landed:
TypeError: App\Models\Account::cachedPostCount(): Return value must
be of type int, string returned at app/Models/Traits/HasUsage.php:80
Root cause: Laravel's RedisStore optimises is_numeric values by storing
them raw (not serialised) so they remain INCR/DECR-able atomically.
The side effect is that an int written via Cache::put comes back as a
string on read. The strict ': int' return type on cachedPostCount then
threw a TypeError.
Local dev and CI used the file/array/database drivers respectively,
which serialise everything blindly and preserve the int type, so the
bug never surfaced before deploy.
Fixes:
- Cast the Cache::remember result to (int) — defensive, survives any
driver-specific behaviour. Documented inline so the cast is not
later removed as redundant.
- Change config/cache.php default from 'database' to 'redis' so local
dev matches prod by default and similar driver-specific bugs surface
before merge instead of after deploy.
- Regression test that seeds the cache with a literal string (mimics
the production Redis read) and asserts cachedPostCount still returns
an int.
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
The TrackBilling job was being enqueued on every Stripe webhook event
even with POSTHOG_ENABLED=false. handle() short-circuited via the
isEnabled() check, so nothing reached PostHog, but the job still
consumed queue worker cycles (20-300ms each) on accounts where Stripe
fires its frequent customer.subscription.updated events.
Adds the missing isEnabled() check at the dispatch site in
trackPlanChange, plus the two test cases that would have caught this
the first time around (TrackBilling not dispatched when enabled=false,
and not dispatched when api_key is missing). Updates the listener test
beforeEach to opt the suite into the enabled path so the existing
assertDispatched() assertions continue to fire.
Two follow-ups from the final pre-merge review:
1. SyncUser and TrackBilling shipped \$account->plan?->slug, which is a
PlanSlug backed enum. json_encode renders it correctly on the wire,
but the queue payload carries an enum instance — anyone introspecting
the job (Bus::fake, future workers) and string-comparing the slug
would fail silently. Cast to ->slug->value at the call sites.
2. PostHogServiceTest only covered the api_key=null negative case. The
primary scenario the gate exists to defend (self-hosted with a stale
POSTHOG_API_KEY but POSTHOG_ENABLED=false) was not asserted. Added
four tests covering capture/identify/groupIdentify with enabled=false
and an api key present, plus a direct truth-table check on
isEnabled() requiring both flags.
Three fixes from a fresh code review:
1. SyncUser identify used 'email' / 'name' instead of the PostHog
special person properties '\$email' / '\$name'. The frontend already
used the correct keys; the backend identify (sole source for users
who sign up but never log in) would have populated only custom
properties, leaving the built-in person profile email/name blank
in the PostHog UI.
2. handleSubscriptionDeleted now short-circuits when plan_id is already
null. Stripe re-delivers webhooks on transient failures, and the
prior version would dispatch a duplicate 'subscription.cancelled'
event and re-flush the (already empty) Pennant cache on each retry.
3. useTracking composable called posthog.capture directly, bypassing
the new enabled gate. While posthog-js queues calls before init
(so no events leaked over the network in self-hosted mode), the
buffer grew unbounded and would fire all queued events in bulk if
init was ever called. Replaced with a gated captureEvent helper
exported from posthog.ts.
Plus: drop the now-trivial 'updating non-plan fields does not flush
the pennant cache' test (no observer to test against), refresh stale
doc comments referencing the removed SyncUserToPostHog filename, and
add a Bus::assertNotDispatched check to the deletion-idempotency test.