Commit graph

978 commits

Author SHA1 Message Date
Paulo Castellano
e61d12ea94 Show only the alt text on the thumbnail, without the icon 2026-07-11 13:01:02 -03:00
Paulo Castellano
797750a1c8 Show the alt text on the thumbnail and drop the file-size badge 2026-07-11 12:49:56 -03:00
Paulo Castellano
6b39616be2 Cover the composer alt-text round-trip with a browser test 2026-07-11 10:52:26 -03:00
Paulo Castellano
3397b829c2 Show the alt-text length limit in the editor dialog 2026-07-11 10:49:31 -03:00
Paulo Castellano
f74053b118 Extract alt-text truncation onto MediaItem and close test gaps
Centralize the per-platform mb_substr truncation that every publisher was
repeating into MediaItem::altTextFor(Platform), delete each publisher's
private altFor() helper, and clarify the Platform::altTextMaxLength()
docblock so it doesn't imply Instagram's documented 1000-char cap is a
guess. Add the assertions review flagged as missing: LinkedInPage/
InstagramFacebook alt-text caps, non-string and literal-"0" alt_text
normalization, altTextFor() truncation/unsupported-platform behavior,
Mastodon's no-description-part case, and the public API's accept/reject
path for media.*.meta.alt_text.
2026-07-11 10:34:57 -03:00
Paulo Castellano
0e93d7e5e3 Normalize alt-text null checks across publishers
Use an explicit `!== null` guard in the Facebook, Instagram, Threads, and
Discord publishers instead of a truthy `if ($alt = ...)`, matching the
Pinterest/X/Mastodon style. The truthy form dropped a literal "0" alt; the
explicit check treats every non-null description uniformly. Also add the
missing PHPDoc to Platform::supportsAltText().
2026-07-11 10:02:50 -03:00
Paulo Castellano
8d7948cb2e Add per-image alt text editor to the post composer 2026-07-11 09:45:52 -03:00
Paulo Castellano
f12f525d54 Publish per-image alt text to Discord attachments 2026-07-11 09:34:57 -03:00
Paulo Castellano
6115362c07 Read Pinterest pin alt text from the image, not per-platform meta 2026-07-11 09:29:39 -03:00
Paulo Castellano
b1162cb67a Publish per-image alt text to Threads containers 2026-07-11 09:25:07 -03:00
Paulo Castellano
9a86aba235 Publish per-image alt text to Instagram image containers 2026-07-11 09:18:58 -03:00
Paulo Castellano
2c81416bd0 Publish per-image alt text to Facebook photos 2026-07-11 09:13:05 -03:00
Paulo Castellano
941e517b2e Publish per-image alt text to LinkedIn images and carousels 2026-07-11 09:08:15 -03:00
Paulo Castellano
65b5007784 Publish per-image alt text to Mastodon media description 2026-07-11 09:02:26 -03:00
Paulo Castellano
4beced64a8 Publish per-image alt text to X via media metadata 2026-07-11 08:56:42 -03:00
Paulo Castellano
555957de61 Publish per-image alt text to Bluesky image embeds 2026-07-11 08:50:13 -03:00
Paulo Castellano
521cfca724 Validate per-image media alt_text (max 2000) in PostMediaRules 2026-07-11 08:46:09 -03:00
Paulo Castellano
603ac84b5f Add MediaItem::altText() accessor for per-image alt text 2026-07-11 08:41:10 -03:00
Paulo Castellano
30a94341f9 Add Platform::altTextMaxLength() as the alt-text cap source of truth 2026-07-11 08:38:42 -03:00
Paulo Castellano
eee0a4a499
Merge pull request #176 from trypostit/feat/paid-first-month-checkout
Charge $1 for the first month instead of a free trial
2026-07-09 16:07:49 -03:00
Paulo Castellano
2c1cd4ec61 Ignore never-started subscriptions when detecting a first-time customer
A raw subscriptions()->exists() check treated a leftover incomplete /
incomplete_expired row — which Cashier persists when a first payment
fails or a 3DS challenge is abandoned — as a prior subscription, so a
genuinely new customer retrying after a failed first attempt lost the
$1 coupon and was charged full price. Exclude those never-started
statuses so only a subscription that actually started (active, canceled,
etc.) marks the account as a returning customer.
2026-07-09 16:03:25 -03:00
Paulo Castellano
3dd4621f57 Limit the $1 first month to a new customer's single workspace
The fixed amount_off coupon only nets $1 for a quantity of one, and the
offer is meant for genuinely new signups. A lapsed account that kept
several workspaces and re-subscribes through onboarding would otherwise
be charged N*price - amount_off (not $1), and a returning customer could
whittle down to one workspace to claim the discount again.

Apply the coupon only when the paid first month is enabled, the account
bills a single workspace, and it has never subscribed before; every other
checkout falls back to allowing promotion codes at the full price.
2026-07-09 15:51:50 -03:00
Paulo Castellano
f36881cb6a Fail loud on a missing coupon and block unpaid extra workspaces
Two hardening fixes for the paid first month:

- FirstMonthCheckoutDiscount throws when the paid first month is enabled
  but STRIPE_FIRST_MONTH_COUPON_ID is unset, instead of silently charging
  every new customer the full price with no discount.
- Guard workspace store() with the same active-subscription check create()
  already applies, so a direct POST can't bootstrap a second billable
  workspace and inflate checkout quantity past the fixed first-month coupon.
2026-07-09 15:37:25 -03:00
Paulo Castellano
c940826031 Charge $1 for the first month instead of a free trial at checkout
A $0 trial only runs a weak card authorization, so invalid cards slip
through and only fail once the real charge fires days later. Applying a
$11-off, duration=once Stripe coupon at checkout charges $1 for real on
the first invoice instead, which validates the card immediately, then
reverts to the full monthly price on the next invoice with no manual
swap needed.
2026-07-09 15:18:00 -03:00
Paulo Castellano
d6c40d425c
Merge pull request #174 from trypostit/feat/avatar-logo-crop-native
Crop the avatar/logo before upload with a native selection cropper
2026-07-04 16:16:45 -03:00
Paulo Castellano
44127c4890 Remove the Vitest frontend unit-test layer
The project's test strategy is backend + e2e only, so drop the Vitest setup that had been added for the crop math: the imageCrop.test.ts suite, vitest.config.ts, the dependency and test:unit script, the CI step, and the test:all reference. The crop math is exercised through the Pest browser test.
2026-07-04 10:53:08 -03:00
Paulo Castellano
de75ef4703 Bump release-docker actions to Node 24
Move checkout, upload-artifact, and download-artifact to their current majors so the release image build stops relying on the deprecated Node 20 runtime. Docker actions are left as-is (not flagged). Only runs on release tags, so it is not exercised by PR CI.
2026-07-04 10:35:36 -03:00
Paulo Castellano
45d0dbf510 Bump CI actions to Node 24 and shard the e2e job
Move actions/checkout, setup-node, cache, and upload-artifact to their current majors (Node 24 runtime). Run the browser suite as a sharded matrix aggregated by an e2e-gate job, so the required status check stays a stable name as the shard count scales. Vitest runs once (shard 1). Ready for the incoming e2e test expansion.
2026-07-04 10:28:17 -03:00
Paulo Castellano
2d72d74cc8 Split CI into parallel backend and e2e jobs
Extract the shared PHP/Composer/Node/database/Passport setup into a composite action, and run the backend suite (Unit + Feature) and the frontend/e2e suite (Vitest + Playwright browser tests) as two parallel jobs. This isolates the flaky browser suite so it can be rerun on its own, surfaces failures by area, gives the growing e2e suite room to shard, and uploads Playwright artifacts on failure.
2026-07-04 10:14:01 -03:00
Paulo Castellano
ef81ea74a8 Add frontend unit tests and harden the cropper's test coverage
Add Vitest (test:unit) with exact-value tests for the crop math (containScale, defaultSelection, clampSelection, all resizeSelection corners, and mime/filename resolution), and wire it plus the Pest browser test into CI. The browser test now samples output pixels against a four-quadrant fixture, so it detects a blank, flipped, or wrong crop rather than only asserting a 512x512 canvas. Add composer test:all to run PHP + Vitest + browser tests locally.
2026-07-04 09:56:38 -03:00
Paulo Castellano
36ccc570e0 Rewrite the image cropper as a movable/resizable selection box
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.
2026-07-04 09:56:31 -03:00
Paulo Castellano
2549a82e9b Address review: cropper mime/zoom/error fixes, harden browser test
- 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).
2026-07-03 22:07:23 -03:00
Paulo Castellano
c6369a6ddb Crop the avatar/logo before upload with a dependency-free cropper
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.
2026-07-03 21:46:19 -03:00
Paulo Castellano
f609073b9a
Merge pull request #173 from trypostit/fix/autofill-save-logo
Save the autofilled site logo as the workspace logo on brand settings update
2026-07-03 20:19:08 -03:00
Paulo Castellano
3bc481ac07 Make LogoAttacher honor its swallow-all contract; drop the caller try/catch
LogoAttacher::attach promised in its docblock that any failure — including a
persistence error — is logged and swallowed so the caller need not handle it.
But the persistence block was try/finally with no catch, so a Throwable from
clearMediaCollection/addMediaFromPath escaped. Both call sites (store and
updateSettings) each wrapped the call in an identical try/catch + Log::warning
to compensate — a band-aid duplicated across the controller.

Fix it at the root: the persistence block now catches Throwable, logs it, and
returns false, matching the documented contract. Both controller call sites
collapse to a single attach() line, and the now-unused Log/Throwable imports
are dropped.

Adds LogoAttacherTest covering the success path, the swallowed persistence
failure, a failed fetch, and a rejected mime type.
2026-07-03 20:15:37 -03:00
Paulo Castellano
b7773849ec Save the autofilled site logo as the workspace logo on brand settings update
Brand autofill on the workspace settings page already captured the site logo
and rendered a preview beneath the URL, but the update flow never persisted it.
The store flow attached it via LogoAttacher; the update flow was missing all
three legs: the form field, the request rule, and the controller attach.

- BrandTab: add logo_url to the useForm payload so autofill can set it and the
  form submits it.
- UpdateWorkspaceRequest: validate logo_url (nullable url) — FormRequest strips
  any unvalidated key, so without a rule it was silently dropped.
- WorkspaceController::updateSettings: pull logo_url out of the validated data
  (it is not a column) and attach it through LogoAttacher, mirroring store.
2026-07-03 20:05:54 -03:00
Paulo Castellano
6f13e2c8b4
Merge pull request #125 from rallisf1/feat/content_languages
Add more content languages
2026-07-03 19:49:56 -03:00
Paulo Castellano
68887147b7
Merge branch 'main' into feat/content_languages 2026-07-03 19:43:09 -03:00
Paulo Castellano
21c4880151
Merge pull request #145 from dantaspaulo/fix/prompts-no-em-dash
feat(prompts): make 'no em/en dashes' an explicit rule in the content prompts
2026-07-03 19:42:52 -03:00
Paulo Castellano
66b7ef625e
Merge branch 'main' into fix/prompts-no-em-dash 2026-07-03 19:39:24 -03:00
Paulo Castellano
56d7fa4033
Merge branch 'main' into feat/content_languages 2026-07-03 19:38:37 -03:00
Paulo Castellano
ec808bdea8 Fix the remaining reka trigger-width dropdowns for Tailwind v4
The workspace switcher menu, the label filter, and the templates category
popover still used the v3 bracket syntax w-[--reka-*-trigger-width], which
Tailwind v4 compiles to an invalid `width: --reka-...` so the floating content
no longer matched its trigger. Switch them to the v4 CSS-variable syntax
w-(--reka-*-trigger-width), matching the FontPicker/LanguagePicker fix.
2026-07-03 19:32:52 -03:00
Paulo Castellano
de4d5f3c68 Share a ContentLanguageOption type and close the review's test gaps
- Extract ContentLanguageOption into @/types and use it for availableContentLanguages
  across BrandForm, BrandTab, Brand, Create, and the LanguagePicker options, so the
  englishName field that drives search is visible to TypeScript instead of being
  dropped silently by the pass-through prop types.
- Add BrandAnalyzerTest: assert the language schema enum equals the full 15-language
  set (guards against it shrinking back to a hardcoded subset behind ::fake()) and
  that instructions() lists every code.
- Isolate the "LLM language wins" autofill test by declaring the page as `en` while
  the LLM returns `de`, so it actually proves mergeLlm precedence instead of both
  paths agreeing.
- Cover SetLocale's cookie side effect: the default locale cookie is set on an
  invalid/absent cookie and left untouched for a valid locale.
2026-07-03 19:28:28 -03:00
Paulo Castellano
5a96bb490a Make the language and font dropdowns match their trigger width
The PopoverContent used w-[--reka-popover-trigger-width], which Tailwind v4
compiles to the invalid `width: --reka-popover-trigger-width`, so the dropdown
collapsed to its content width. Use the v4 CSS-variable syntax
w-(--reka-popover-trigger-width) so it resolves to var(...) and spans the full
trigger width, matching the SearchableSelect component.
2026-07-03 19:01:16 -03:00
Paulo Castellano
c9209cb2a9 Replace the boolean isRtl() with a direction() accessor on ContentLanguage
isRtl() existed only to be mapped to an 'rtl'/'ltr' string in SetLocale, so the
boolean was the redundant concept. direction() returns the string the one caller
needs, which drops the null-safe-plus-nested-ternary from the middleware and lets
it resolve the language once with an explicit DEFAULT fallback.
2026-07-03 18:57:09 -03:00
Paulo Castellano
c7be1af124 Detect all 15 content languages on autofill, harden RTL/i18n, and cover the gaps with tests
- 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.
2026-07-03 18:52:45 -03:00
Paulo Castellano
519175f9fd Translate the sidebar Beta badge and place it at the row end for RTL
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.
2026-07-03 18:22:19 -03:00
Paulo Castellano
e17b86ac93 Make the content-language field a searchable combobox and reload on UI-language switch
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.
2026-07-03 18:17:38 -03:00
Paulo Castellano
c59adb4871 Cover every supported language in ContentLanguage detection tests
Extend the homepage-language detection dataset to assert all 15 supported
languages resolve from their <html lang> subtag, and pin the primary-subtag
matching so a malformed tag ("english") no longer resolves via a two-letter
prefix.
2026-07-03 15:39:35 -03:00
Paulo Castellano
6797706921 Translate the interface into 12 languages
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.
2026-07-03 15:38:40 -03:00