diff --git a/.ai/rules/jobs-post-hog.md b/.ai/rules/jobs-post-hog.md index e608e1b086..3c3a2a7229 100644 --- a/.ai/rules/jobs-post-hog.md +++ b/.ai/rules/jobs-post-hog.md @@ -7,7 +7,7 @@ paths: # Jobs Post Hog ## Sync PostHog billing state after Cashier -Listen to Cashier's WebhookHandled event so local subscription state is persisted before PostHog jobs are dispatched. Account group properties are the current snapshot: subscription_status is canceled as soon as an ends_at exists (including grace period), while has_active_subscription remains true until access ends. Keep first_month_offer_ends_at event-derived and set it only on subscription creation so renewals cannot extend it. +Listen to Cashier's WebhookHandled event so local subscription state is persisted before PostHog jobs are dispatched. Account group properties are the current snapshot: subscription_status is canceled as soon as an ends_at exists (including grace period), while has_active_subscription remains true until access ends. Keep the group's offer_slug event-derived and set it only on subscription creation so renewals cannot change it. ## Stripe webhooks without a Cashier handler go through StripeBillingSignalListener Cashier fires `WebhookHandled` only for types with a `handle*` method. `checkout.session.expired`, `invoice.payment_failed` and `invoice.upcoming` have none, so `StripeBillingSignalListener` listens to `WebhookReceived` for them. diff --git a/.env.example b/.env.example index 3112b931e4..0b8176bde7 100644 --- a/.env.example +++ b/.env.example @@ -261,25 +261,11 @@ AI_AUDIO_PROVIDER=elevenlabs STRIPE_KEY= STRIPE_SECRET= STRIPE_WEBHOOK_SECRET= -# SaaS default (recipe A): 8-day Stripe trial with card, no coupon, no promo field. +# Discounts, trials and the promotion code field are offers (offers table). # REQUIRE_CARD_FOR_TRIAL=true forces Checkout before app access (no generic trial). REQUIRE_CARD_FOR_TRIAL=true -# Trial length in days. Card-required Checkout uses trialDays for first-time -# subscribers when no first-month coupon is applied; re-subscribers skip trial. -# No-card mode uses this for accounts.trial_ends_at. 0 = off. +# No-card mode only: length of the signup trial on accounts.trial_ends_at. 0 = off. CASHIER_TRIAL_DAYS=8 -# Per-plan first-month coupons (amount_off, duration=once) so month one is $1. -# Socials $19 − $18, Workspaces $99 − $88. When set for a qualifying first-time -# monthly checkout of that plan, applies the coupon and SKIPS trialDays. -# Empty for that plan = trial mode above. Never reuse one coupon on the other -# plan. Yearly prices never get a coupon. XOR with CASHIER_ALLOW_PROMOTION_CODES -# only when the coupon would apply — Stripe forbids both on one session -# (ConfigureSubscriptionCheckout throws). -STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID= -STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID= -# Show Stripe Checkout promotion-code field when no coupon is applied. -# Defaults to false (recipe A). Must be false when a first-month coupon applies. -CASHIER_ALLOW_PROMOTION_CODES=false # Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder. STRIPE_WORKSPACE_MONTHLY= diff --git a/AGENTS.md b/AGENTS.md index 6f41460d08..5ee6ed3801 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -286,25 +286,21 @@ Every lang string is rendered in all 16 locales, and the longest one decides the - A rewrite of existing text (shorten, rephrase, tone) keeps the text's own language; only new text is written in `{{ $language }}`. - AI is web only: no REST API endpoint and no MCP tool calls an AI agent. -## Stripe Checkout (env knobs) +## Stripe Checkout (offers and env knobs) -Checkout options are configured only via env — do not hardcode trial/coupon/promo behavior in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`). +Everything that is not the full price is an offer (owner decision October 2026): the discount, the trial and the promotion code field live on the `offers` row (see "Offers"), never in env or in controllers. All of it goes through `App\Support\Billing\ConfigureSubscriptionCheckout` (called from `StartSubscriptionCheckout`). | Env | Config | Default | Effect | | --- | --- | --- | --- | | `REQUIRE_CARD_FOR_TRIAL` | `trypost.billing.require_card_for_trial` | `true` | `true`: app access only after Stripe Checkout (no generic signup trial). `false`: generic `accounts.trial_ends_at` trial without a card | -| `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | Card-required Checkout: `trialDays(N)` for **first-time** subscribers when no first-month coupon is applied (`0` = off). Re-subscribers skip trial. No-card mode: length of the generic signup trial | -| `STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.socials` | empty | Optional. `$18` off Socials monthly (`$19` → `$1`). When set for a qualifying first-time **monthly** checkout of that plan, applies `withCoupon` and **skips** trial. Empty = trial mode | -| `STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_ids.workspaces` | empty | Optional. `$88` off Workspaces monthly (`$99` → `$1`). Same qualification as the Socials coupon. Never reuse one coupon on the other plan | -| `CASHIER_ALLOW_PROMOTION_CODES` | `cashier.allow_promotion_codes` | `false` | When `true` and no coupon is applied, show the Checkout promo-code field | +| `CASHIER_TRIAL_DAYS` | `cashier.trial_days` | `8` | No-card mode only: length of the generic signup trial (`0` = off). The Checkout trial is an offer's `trial_days` | Standing constraints: -- Stripe rejects `discounts` (coupon) and `allow_promotion_codes` on the same session — if both would apply, `ConfigureSubscriptionCheckout` must throw (fail loud). Never “prefer one silently.” Envs may both be set when the account does **not** qualify for the coupon (no throw). -- A set first-month coupon wins over trial (`trialDays` is skipped for that checkout). -- Empty coupon + card required + first-time must use `trialDays` — do **not** reintroduce a required-coupon throw. -- Coupon qualification stays: card required, no prior real subscription (`incomplete` / `incomplete_expired` still qualify), **and** the checkout price is that plan's **monthly** price. Workspace count is irrelevant — Socials is already capped at one, and a first-time Workspaces subscriber qualifies the same way. -- First-month coupons are **per plan**. Socials is `$18` off, Workspaces is `$88` off. Never apply one plan's coupon to the other price, and never apply either coupon to a yearly price — `$190 − $18` is not `$1`. -- Welcome checkout is the last Welcome step, the plan step (`app.welcome.plan.store`): it shows only **Socials**, monthly, with the first-month offer, and always opens Stripe Checkout on the **Socials monthly** price. Yearly and Workspaces stay on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). +- Without an offer the checkout charges the full price at once: no coupon, no trial, no promotion code field. +- An offer applies what its row sets: `stripe_coupon_id` (the discount), `trial_days` (clamped to Stripe's 2-day minimum) and `allow_promotion_codes`. Stripe rejects a coupon and promotion codes on one session but accepts a trial with either, so a coupon wins and the field is left off; a trial goes with both. +- There are no first-month coupon or promotion code envs any more (`STRIPE_*_FIRST_MONTH_COUPON_ID` and `CASHIER_ALLOW_PROMOTION_CODES` were removed in October 2026). Every offer reaches an account through its link: the 7-day trial (`free-trial-7-days`, `trial_days` 8 because Stripe Checkout counts the trial one day short and shows 8 as 7 days, `first_subscription_only`), the `$1` first month (`first-month`, `first_subscription_only`) and Black Friday (`bf26`). There is no default offer: a signup without a link pays the full price. Do not reintroduce a checkout env. +- First-subscription offers skip an account with a prior real subscription (`incomplete` / `incomplete_expired` still qualify). +- Welcome checkout is the last Welcome step, the plan step (`app.welcome.plan.store`): it shows only **Socials**, monthly, and always opens Stripe Checkout on the **Socials monthly** price. Offers apply only there. Yearly and Workspaces stay on the billing change-plan picker for existing subscribers (no offer). - Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. ## Plans and the workspace limit @@ -338,12 +334,9 @@ used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. `workspaces.limit_reached`. Without a subscription it still goes to billing. - POST at the cap is refused the same way (to the list, not 403'd, never to create), tracked once by `store` itself after its transaction. -- First-month coupon qualification is card required + first-time subscriber + - that plan's monthly price. Workspace count is not part of it. - (`incomplete` / `incomplete_expired` still qualify; coupon + - `allow_promotion_codes` still throws.) Each plan has its own coupon. -- Welcome checks out Socials monthly only so the `$1` first month can exist. - Billing keeps yearly and Workspaces for subscribers changing plan. +- Welcome checks out Socials monthly only, so an offer's price is always about + Socials monthly. Billing keeps yearly and Workspaces for subscribers changing + plan. - The legacy plan is archived: it never appears in the picker, so nobody can move back to it. Its `workspace_limit` is 1 because it costs less than Socials. - Welcome's plan step (`app.welcome.plan`, after the current-tool step) shows @@ -363,6 +356,74 @@ used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. `AccountPolicy::useAi` checks app access and nothing else. Do not reintroduce per-call usage recording. +## Offers (`?offer=` links) + +An offer is anything the Welcome checkout gives instead of the full price +(owner decision October 2026): a Stripe coupon, a trial, the promotion code +field, or a mix. The 7-day trial (`free-trial-7-days`), the `$1` first month +(`first-month`) and campaigns such as Black Friday (`bf26`) are all offers. It +lives in `offers` (`slug` and `starts_at` required; `stripe_coupon_id`, +`trial_days`, `ends_at` and `max_redemptions` nullable: no coupon, no trial, no +end, no limit; `allow_promotion_codes`, `first_subscription_only` and +`show_banner` false by default) and reaches an account through the pivot +`account_offer` (`claimed_at`, `redeemed_at`). + +- **Capture.** `CaptureOffer` (web middleware) reads `?offer=` on any page. + A signed-in owner claims it at once (`ClaimOffer`). A guest's slug waits in the + session, only when it names an available offer, and the first authenticated + request claims it, so email signup, Google, GitHub and login need no code of + their own; moving between register and login keeps it. Slugs match exactly on + both engines through `Offer::findAvailable()` (MySQL compares without case). Ads and landing pages link to `/register?offer=`, win-back emails to + `/login?offer=`. There is no offer page or route. +- **Who gets it.** Only an account owner whose account has no active + subscription: new signups and former customers alike, unless the offer is + `first_subscription_only`, which skips an account that was ever billed + (`hasHadBillableSubscription()`). `Offer::scopeAvailableTo()` holds both rules + and drops offers the account redeemed. An unknown, not yet started, ended or + full offer is ignored silently and nothing is kept for later. A redeemed offer + is never offered to that account again. +- **The `offers` row is the only source of truth for dates and places.** The + Stripe coupon has no `redeem_by` and no `max_redemptions`. Only redemptions use + up a place (`Offer::scopeAvailable`), never a claim; two checkouts racing for the + last place may both get it (accepted). +- **Checkout.** `Account::currentOffer()` picks the latest claim the account may + still use, else none (full price). + `ConfigureSubscriptionCheckout::apply()` applies it. Validity is checked again + when checkout starts. The controller resolves the offer once (`offerFor()`) and + hands the same one to `StartSubscriptionCheckout` and `apply()`, so the coupon, + the metadata and the tracked events agree. The offer id and slug go in the + subscription metadata (`trypost_offer_id`, `trypost_offer_slug`) and the slug in + the session metadata; the `customer.subscription.created` / `updated` webhook + writes `redeemed_at` (`RedeemOffer`) only once the subscription is `active` or + `trialing`, so an incomplete checkout never uses up a place. Offers apply only + to the Welcome checkout (Socials monthly). +- **Tracking.** PostHog names the offer by its slug, `offer_slug`, on + `checkout.started`, `checkout.completed`, `checkout.expired` and the account + group (set on subscription creation). The old `is_first_month_offer`, + `first_month_coupon_id` and `first_month_offer_ends_at` are gone. +- **Copy.** Each offer's copy lives in `lang//offers.php` under the + slug with dashes as underscores (`free-trial-7-days` → `free_trial_7_days`): `name` (the + banner's tag), `headline` (the banner's sentence), `price`, `period` and an + optional `unit` shown next to the price (`/month` for Black Friday; a trial or + the `$1` first month has none, so their copy decides it, never the code), in all + 16 locales; the price is display text per locale, like + `billing.subscribe.prices.socials.monthly`. Nothing about the discount is + stored in the database, and the banner (`name`, `headline`) is optional: an + offer has both or neither, while `price` and `period` are required + (`OfferCopyTest`). Each trial length is its own offer with its own copy + (`free-trial-7-days`, `free-trial-14-days`), so several can run at once. The page has no fallback language, so `OfferResource` + checks each key in the current locale only. The Welcome plan step shows the + banner (a `bg-foreground` pill, the name in a `bg-background` uppercase tag) only when the row has `show_banner` and `name` and `headline` exist, and the offer price only when `price` and `period` exist; it never + prints a raw key. A trial-only offer's button says + `welcome.plan_start_trial`; without an offer the step shows the full price. + `tests/Feature/Offer/OfferCopyTest.php` fails when an offer misses its copy in + any locale. +- **Adding an offer.** Create its coupon in Stripe when it has one (no dates, no + limit), add its + copy to all 16 locales and deploy, then insert the `offers` row in each + environment's database by hand (owner decision October 2026: no data + migration, since the coupon id differs between Stripe test and live). + ## Multiple social accounts per network A workspace may connect as many accounts of the same network as it wants (two @@ -778,6 +839,7 @@ view turns a forgotten variable into a runtime-only failure. - **Exception — the per-post table on the channel insights page** (owner decision 2026-10-05): it is page-based with numbered pages, 10 posts per page (`ListChannelPublicationPerformance::INSIGHTS_PAGE_SIZE`, through `insightsPage()`), and keeps `?page=` in the URL; a sort, period or filter change goes back to page 1. The API endpoint and the MCP tool for the same list keep the config page size through `handle()`. - **Exception — the ideas board columns** (owner decision 2026-10-06): each stage column (and Unassigned) is its own scroll prop, `columns.`, with its own page name, and loads 10 ideas per page (`ListIdeas::BOARD_PAGE_SIZE`) as the column scrolls. A label filter change resets every column to page 1. Column counts come from the database (`stages.*.ideas_count`, `unassigned_count`), never from the loaded cards. The web board moves a card with `after_idea_id` (`MoveIdea::after()`), since a partly loaded column cannot send the full order; the API and MCP keep the full `idea_ids` contract. The gallery keeps the config page size. - **Exception — the "What's New" modal** (owner decision 2026-10-14): it loads 5 entries per page, enough to fill the modal (capped at 640px) and the next one loads only when the user scrolls (`ListChangelogEntries::PAGE_SIZE`) under the page name `changelog_page`, as the shared optional scroll prop `changelogEntries` (built with the `changelog` card prop by `BuildChangelogProps`, which `HandleInertiaRequests` only spreads), requested by a partial reload when the modal opens. + - **Exception — the invoices tab on the billing page** (October 2026): invoices live in Stripe, which only pages by cursor, so `BillingController@index` sends `Inertia::scroll()` over Cashier's `cursorPaginateInvoices()` (paid invoices only, config page size). It is the one cursor paginator in the app; database lists keep `paginate()`. - **This includes the public REST API** (`app/Http/Controllers/Api`). It used to pin its own page size of 15 as a stable contract; that exception is gone, so a list endpoint reads the same config as everything else. Changing `app.pagination.default` therefore changes the API's page size too — deliberate, and the reason a list response always carries `meta.per_page` for clients to read rather than assume. ## Changelog ("What's New") diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 95c484288c..1b0c99afb9 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -6,6 +6,7 @@ use App\Enums\Billing\Interval; use App\Models\Account; +use App\Models\Offer; use App\Models\Plan; use App\Support\Billing\ConfigureSubscriptionCheckout; use Inertia\Inertia; @@ -13,7 +14,7 @@ class StartSubscriptionCheckout { - public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null): Response + public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null, ?Offer $offer = null): Response { // A leftover Workspaces row (unpaid/canceled without `deleted`) would // let Processing treat auth.plan as checkout-complete and skip the wait. @@ -26,10 +27,15 @@ public function redirect(Account $account, string $priceId, string $cancelUrl, ? $monthlyPlan = $this->monthlyPlan($plan, $priceId); $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId); - ConfigureSubscriptionCheckout::apply($subscription, $account, $monthlyPlan); - $subscription->withMetadata(array_filter([ - ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY => $subscription->couponId, - ], filled(...))); + $offer = filled($monthlyPlan) ? $offer : null; + + $offerMetadata = array_filter([ + ConfigureSubscriptionCheckout::OFFER_METADATA_KEY => $offer?->id, + ConfigureSubscriptionCheckout::OFFER_SLUG_METADATA_KEY => $offer?->slug, + ], filled(...)); + + ConfigureSubscriptionCheckout::apply($subscription, $offer); + $subscription->withMetadata($offerMetadata); $session = $subscription->checkout([ 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', @@ -37,7 +43,7 @@ public function redirect(Account $account, string $priceId, string $cancelUrl, ? 'metadata' => array_filter([ ConfigureSubscriptionCheckout::PLAN_METADATA_KEY => $plan?->slug->value, ConfigureSubscriptionCheckout::INTERVAL_METADATA_KEY => (filled($plan) ? Interval::forPrice($plan, $priceId) : Interval::Monthly)->value, - ConfigureSubscriptionCheckout::FIRST_MONTH_OFFER_METADATA_KEY => filled($subscription->couponId) ? 'true' : 'false', + ConfigureSubscriptionCheckout::OFFER_SLUG_METADATA_KEY => $offer?->slug, ], filled(...)), ]); diff --git a/app/Actions/Offer/ClaimOffer.php b/app/Actions/Offer/ClaimOffer.php new file mode 100644 index 0000000000..ebb7512de6 --- /dev/null +++ b/app/Actions/Offer/ClaimOffer.php @@ -0,0 +1,36 @@ +hasActiveSubscription()) { + return null; + } + + $offer = Offer::findAvailable($slug); + + if (blank($offer)) { + return null; + } + + if ($offer->redemptions()->whereKey($account->id)->exists()) { + return null; + } + + $account->offers()->syncWithoutDetaching([$offer->id => ['claimed_at' => now()]]); + + return $offer; + } +} diff --git a/app/Actions/Offer/RedeemOffer.php b/app/Actions/Offer/RedeemOffer.php new file mode 100644 index 0000000000..5f9404c8d5 --- /dev/null +++ b/app/Actions/Offer/RedeemOffer.php @@ -0,0 +1,27 @@ +offers() + ->wherePivotNull('redeemed_at') + ->updateExistingPivot($offerId, ['redeemed_at' => now()]); + } +} diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 0aec8ad033..2cb742dcfa 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -6,9 +6,13 @@ use App\Enums\Billing\Interval; use App\Http\Requests\App\Billing\ChangePlanRequest; +use App\Http\Resources\App\InvoiceResource; +use App\Http\Resources\App\PaymentMethodResource; use App\Http\Resources\App\PlanResource; +use App\Http\Resources\App\SubscriptionDiscountResource; use App\Models\Account; use App\Models\Plan; +use App\Models\Subscription; use App\Support\Billing\SubscriptionPlanSync; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -16,6 +20,7 @@ use Inertia\Inertia; use Inertia\Response; use Laravel\Cashier\Exceptions\IncompletePayment; +use Laravel\Cashier\PaymentMethod; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class BillingController extends Controller @@ -60,18 +65,52 @@ public function index(Request $request): Response|RedirectResponse ]), 'plan' => $account->plan ? PlanResource::make($account->plan)->resolve() : null, 'workspaceCount' => $account->workspaces()->count(), - 'workspaceLimit' => $account->workspaceLimit(), - 'invoices' => $account->invoices()->map(fn ($invoice) => [ - 'id' => $invoice->id, - 'date' => $invoice->date(), - 'total' => $invoice->total(), - 'status' => $invoice->status, - 'invoice_pdf' => $invoice->invoice_pdf, - ]), - 'defaultPaymentMethod' => $account->displayablePaymentMethod(), + 'invoices' => Inertia::scroll( + fn () => InvoiceResource::collection($account->cursorPaginateInvoices((int) config('app.pagination.default'), ['status' => 'paid'])), + )->matchOn('data.id'), + 'paymentMethods' => $this->paymentMethods($account), + 'discount' => $this->discount($account, $subscription), ]); } + /** + * The customer's cards, the default one first. Stripe Checkout trials anchor + * the card to the subscription, not the customer, so without + * `invoice_settings.default_payment_method` the first card is the default. + * + * @return list + */ + private function paymentMethods(Account $account): array + { + $paymentMethods = $account->paymentMethods('card'); + + if ($paymentMethods->isEmpty()) { + return []; + } + + $defaultId = $account->defaultPaymentMethod()?->id ?? $paymentMethods->first()->id; + + return $paymentMethods + ->sortByDesc(fn (PaymentMethod $paymentMethod): bool => $paymentMethod->id === $defaultId) + ->map(fn (PaymentMethod $paymentMethod): array => PaymentMethodResource::make($paymentMethod, $paymentMethod->id === $defaultId)->resolve()) + ->values() + ->all(); + } + + /** + * @return array{value: string, duration: string, ends_at: string|null}|null + */ + private function discount(Account $account, ?Subscription $subscription): ?array + { + if (blank($subscription) || ! $account->hasStripeId()) { + return null; + } + + $discount = $subscription->discount(); + + return filled($discount) ? SubscriptionDiscountResource::make($discount)->resolve() : null; + } + public function changePlan(ChangePlanRequest $request): RedirectResponse { if (config('trypost.self_hosted')) { diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index aa06d57a3d..40cd55d17b 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -17,6 +17,7 @@ use App\Http\Requests\App\Welcome\StoreWelcomeMcpConnectClickRequest; use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; use App\Http\Requests\App\Welcome\StoreWelcomeSoftwareRequest; +use App\Http\Resources\App\OfferResource; use App\Http\Resources\App\PlanResource; use App\Models\Plan; use App\Services\PostHogService; @@ -154,10 +155,11 @@ public function plan(Request $request): InertiaResponse|RedirectResponse } $plan = Plan::active()->where('slug', Slug::Socials)->firstOrFail(); + $offer = ConfigureSubscriptionCheckout::offerFor($request->user()->account, $plan); return Inertia::render('welcome/Plan', [ 'plan' => PlanResource::make($plan)->resolve(), - 'firstMonthOffer' => ConfigureSubscriptionCheckout::offersFirstMonthCoupon($request->user()->account, $plan), + 'offer' => filled($offer) ? OfferResource::make($offer)->resolve() : null, ]); } @@ -176,8 +178,8 @@ public function storePlan( abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Price is not configured.'); - $firstMonthOffer = ConfigureSubscriptionCheckout::offersFirstMonthCoupon($user->account, $plan); - $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan); + $offer = ConfigureSubscriptionCheckout::offerFor($user->account, $plan); + $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan, $offer); try { $postHog->capture( @@ -186,7 +188,7 @@ public function storePlan( [ 'plan_name' => $plan->name, 'interval' => Interval::Monthly->value, - 'is_first_month_offer' => $firstMonthOffer, + 'offer_slug' => $offer?->slug, ], $user->account, ); diff --git a/app/Http/Middleware/App/CaptureOffer.php b/app/Http/Middleware/App/CaptureOffer.php new file mode 100644 index 0000000000..46d62d6701 --- /dev/null +++ b/app/Http/Middleware/App/CaptureOffer.php @@ -0,0 +1,47 @@ +query('offer'); + $slug = is_string($slug) && filled($slug) ? $slug : null; + $user = $request->user(); + + if (blank($user)) { + if (filled($slug) && filled(Offer::findAvailable($slug))) { + $request->session()->put(self::SESSION_KEY, $slug); + } + + return $next($request); + } + + $pendingSlug = $request->session()->pull(self::SESSION_KEY); + $slug ??= $pendingSlug; + + if (filled($slug) && $user->isAccountOwner() && filled($user->account)) { + ClaimOffer::execute($user->account, $slug); + } + + return $next($request); + } +} diff --git a/app/Http/Resources/App/InvoiceResource.php b/app/Http/Resources/App/InvoiceResource.php new file mode 100644 index 0000000000..d7640325e5 --- /dev/null +++ b/app/Http/Resources/App/InvoiceResource.php @@ -0,0 +1,31 @@ + $this->id, + 'date' => $this->date()->toIso8601String(), + 'total' => $this->total(), + 'status' => $this->status, + 'invoice_pdf' => $this->invoice_pdf, + ]; + } +} diff --git a/app/Http/Resources/App/OfferResource.php b/app/Http/Resources/App/OfferResource.php new file mode 100644 index 0000000000..72be385f19 --- /dev/null +++ b/app/Http/Resources/App/OfferResource.php @@ -0,0 +1,42 @@ +/offers.php. The page has no + * fallback language, so each key is checked in the current locale only and + * the page never prints a raw key. + * + * @mixin Offer + */ +class OfferResource extends JsonResource +{ + /** + * @return array{key: string, trial_days: int|null, has_coupon: bool, has_banner: bool, has_price: bool, has_unit: bool} + */ + public function toArray(Request $request): array + { + $key = $this->langKey(); + + return [ + 'key' => $key, + 'trial_days' => $this->trial_days, + 'has_coupon' => filled($this->stripe_coupon_id), + 'has_banner' => $this->show_banner && $this->hasCopy("offers.{$key}.name") && $this->hasCopy("offers.{$key}.headline"), + 'has_price' => $this->hasCopy("offers.{$key}.price") && $this->hasCopy("offers.{$key}.period"), + 'has_unit' => $this->hasCopy("offers.{$key}.unit"), + ]; + } + + private function hasCopy(string $key): bool + { + return Lang::has($key, null, false); + } +} diff --git a/app/Http/Resources/App/PaymentMethodResource.php b/app/Http/Resources/App/PaymentMethodResource.php new file mode 100644 index 0000000000..9b8ca531c8 --- /dev/null +++ b/app/Http/Resources/App/PaymentMethodResource.php @@ -0,0 +1,37 @@ + $this->id, + 'brand' => $this->card->brand, + 'last4' => $this->card->last4, + 'exp_month' => $this->card->exp_month, + 'exp_year' => $this->card->exp_year, + 'is_default' => $this->isDefault, + ]; + } +} diff --git a/app/Http/Resources/App/SubscriptionDiscountResource.php b/app/Http/Resources/App/SubscriptionDiscountResource.php new file mode 100644 index 0000000000..56b042a054 --- /dev/null +++ b/app/Http/Resources/App/SubscriptionDiscountResource.php @@ -0,0 +1,36 @@ +coupon(); + + return [ + 'value' => $coupon->isPercentage() + ? Number::percentage($coupon->percentOff(), maxPrecision: 2, locale: App::currentLocale()) + : $coupon->amountOff(), + 'duration' => $coupon->duration(), + 'ends_at' => $this->end()?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php index 9e9d638347..08a1c9ae0b 100644 --- a/app/Jobs/PostHog/TrackBilling.php +++ b/app/Jobs/PostHog/TrackBilling.php @@ -64,7 +64,7 @@ public function handle(PostHogService $postHog): void 'subscription_status' => $account->subscriptionStatus(), 'has_active_subscription' => $account->hasActiveSubscription(), ...$this->currentCancellationProperties($account), - ...$this->firstMonthOfferProperties(), + ...$this->offerProperties(), ]); SyncUser::dispatch((string) $account->owner_id); @@ -113,14 +113,14 @@ private function cancelReasonProperties(): array /** * @return array */ - private function firstMonthOfferProperties(): array + private function offerProperties(): array { if ($this->event !== BillingEvent::Created) { return []; } return [ - 'first_month_offer_ends_at' => StripeSubscriptionConversion::firstMonthOfferEndsAt($this->payload), + 'offer_slug' => StripeSubscriptionConversion::offerSlug($this->payload), ]; } } diff --git a/app/Jobs/PostHog/TrackCheckoutExpired.php b/app/Jobs/PostHog/TrackCheckoutExpired.php index 16c0939223..d9f9c7446c 100644 --- a/app/Jobs/PostHog/TrackCheckoutExpired.php +++ b/app/Jobs/PostHog/TrackCheckoutExpired.php @@ -22,7 +22,7 @@ protected function event(): string } /** - * @return array{plan_name: string|null, interval: string|null, is_first_month_offer: bool, checkout_session_id: string|null} + * @return array{plan_name: string|null, interval: string|null, offer_slug: string|null, checkout_session_id: string|null} */ protected function properties(Account $account): array { @@ -32,7 +32,7 @@ protected function properties(Account $account): array return [ 'plan_name' => filled($planSlug) ? Plan::where('slug', $planSlug)->value('name') : null, 'interval' => data_get($metadata, ConfigureSubscriptionCheckout::INTERVAL_METADATA_KEY), - 'is_first_month_offer' => data_get($metadata, ConfigureSubscriptionCheckout::FIRST_MONTH_OFFER_METADATA_KEY) === 'true', + 'offer_slug' => data_get($metadata, ConfigureSubscriptionCheckout::OFFER_SLUG_METADATA_KEY), 'checkout_session_id' => data_get($this->payload, 'data.object.id'), ]; } diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index fa3f754e92..b5373594dd 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -4,6 +4,7 @@ namespace App\Listeners; +use App\Actions\Offer\RedeemOffer; use App\Enums\PostHog\BillingEvent; use App\Jobs\PostHog\TrackBilling; use App\Jobs\PostHog\TrackCheckoutCompleted; @@ -13,6 +14,7 @@ use App\Models\Account; use App\Models\Plan; use App\Services\PostHogService; +use App\Support\Billing\ConfigureSubscriptionCheckout; use App\Support\Billing\SubscriptionPlanSync; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; @@ -61,7 +63,7 @@ public function handle(WebhookHandled $event): void /** * A subscription is born either `trialing` (card-required trial, no charge - * yet) or `active` (first-month coupon or no trial, charged at once): two + * yet) or `active` (an offer without a trial, or no offer, charged at once): two * business events, see App\Support\StripeSubscriptionConversion. * * @param array $payload @@ -70,6 +72,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload): { $previousPlan = $account->plan?->name; + $this->redeemOffer($account, $payload); $this->syncPlanFromSubscription($account, $payload, clearGenericTrial: true); $this->recordFirstPayment($account, $payload); @@ -85,6 +88,7 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): { $previousPlan = $account->plan?->name; + $this->redeemOffer($account, $payload); $this->syncPlanFromSubscription($account, $payload); $this->recordFirstPayment($account, $payload); @@ -218,6 +222,21 @@ private function resolvePlanFromSubscriptionItems(array $payload, Account $accou return $plan; } + /** + * An offer is used once its subscription is active or trialing: an + * incomplete one may never be paid, and would use up a place for nothing. + * + * @param array $payload + */ + private function redeemOffer(Account $account, array $payload): void + { + if (! SubscriptionPlanSync::allows($this->currentStatus($payload))) { + return; + } + + RedeemOffer::execute($account, data_get($payload, ['data', 'object', 'metadata', ConfigureSubscriptionCheckout::OFFER_METADATA_KEY])); + } + /** * @param array $payload */ diff --git a/app/Models/Account.php b/app/Models/Account.php index 0a683e7f39..4ed90e2170 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; @@ -72,6 +73,20 @@ public function invites(): HasMany return $this->hasMany(Invite::class); } + public function offers(): BelongsToMany + { + return $this->belongsToMany(Offer::class)->withPivot('claimed_at', 'redeemed_at'); + } + + /** + * The offer the Welcome checkout uses: the most recently claimed one the + * account may still use. Without one it pays the full price. + */ + public function currentOffer(): ?Offer + { + return $this->offers()->availableTo($this)->orderByPivot('claimed_at', 'desc')->first(); + } + public function posts(): HasManyThrough { return $this->hasManyThrough(Post::class, Workspace::class); @@ -208,28 +223,4 @@ public function activeTrialEndsAt(): ?CarbonInterface return $subscription->trial_ends_at; } - - /** - * Returns the displayable card for the billing UI. Falls back to the first - * attached payment method when the customer has no `invoice_settings.default_payment_method` - * (Stripe Checkout trials anchor the card to the subscription, not the customer). - * - * @return array{brand: string, last4: string, exp_month: int, exp_year: int}|null - */ - public function displayablePaymentMethod(): ?array - { - $paymentMethod = $this->defaultPaymentMethod() ?? $this->paymentMethods()->first(); - $card = $paymentMethod?->card; - - if (! $card) { - return null; - } - - return [ - 'brand' => $card->brand, - 'last4' => $card->last4, - 'exp_month' => $card->exp_month, - 'exp_year' => $card->exp_year, - ]; - } } diff --git a/app/Models/Offer.php b/app/Models/Offer.php new file mode 100644 index 0000000000..ee9ee73f08 --- /dev/null +++ b/app/Models/Offer.php @@ -0,0 +1,99 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'slug', + 'stripe_coupon_id', + 'trial_days', + 'allow_promotion_codes', + 'starts_at', + 'ends_at', + 'max_redemptions', + 'first_subscription_only', + 'show_banner', + ]; + + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'trial_days' => 'integer', + 'allow_promotion_codes' => 'boolean', + 'max_redemptions' => 'integer', + 'first_subscription_only' => 'boolean', + 'show_banner' => 'boolean', + ]; + } + + public function accounts(): BelongsToMany + { + return $this->belongsToMany(Account::class)->withPivot('claimed_at', 'redeemed_at'); + } + + /** + * The accounts that redeemed the offer. Each one uses up a place. + */ + public function redemptions(): BelongsToMany + { + return $this->accounts()->wherePivotNotNull('redeemed_at'); + } + + /** + * Started, not ended, and with a place left. Only redemptions use up a + * place; a claim alone never does. + */ + public function scopeAvailable(Builder $query): Builder + { + return $query + ->whereNowOrPast('offers.starts_at') + ->where(fn (Builder $query): Builder => $query->whereNull('offers.ends_at')->orWhereFuture('offers.ends_at')) + ->where(fn (Builder $query): Builder => $query->whereNull('offers.max_redemptions')->orHas('redemptions', '<', DB::raw('offers.max_redemptions'))); + } + + /** + * Available offers the account may still use: never one it redeemed, and + * a first-subscription offer only while it has never been billed. + */ + public function scopeAvailableTo(Builder $query, Account $account): Builder + { + return $query + ->available() + ->whereDoesntHave('redemptions', fn (Builder $query): Builder => $query->whereKey($account->id)) + ->when($account->hasHadBillableSubscription(), fn (Builder $query): Builder => $query->where('offers.first_subscription_only', false)); + } + + /** + * The available offer with exactly this slug. MySQL compares strings + * without case, so the match is confirmed here, where both engines agree. + */ + public static function findAvailable(string $slug): ?self + { + return self::available()->where('slug', $slug)->get()->firstWhere('slug', $slug); + } + + /** + * The key of the offer's copy in lang//offers.php. + */ + public function langKey(): string + { + return Str::replace('-', '_', $this->slug); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 64d6b0d569..c459d837c4 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -19,6 +19,7 @@ use App\Models\Media; use App\Models\MediaSourceConnection; use App\Models\NotificationPreference; +use App\Models\Offer; use App\Models\Plan; use App\Models\Post; use App\Models\PostNote; @@ -117,6 +118,7 @@ protected function configureMorphMap(): void 'media' => Media::class, 'mediaSourceConnection' => MediaSourceConnection::class, 'notificationPreference' => NotificationPreference::class, + 'offer' => Offer::class, 'plan' => Plan::class, 'post' => Post::class, 'repurpose' => Repurpose::class, diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php index 4bf0b7336a..c7c472f02a 100644 --- a/app/Support/Billing/ConfigureSubscriptionCheckout.php +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -5,19 +5,19 @@ namespace App\Support\Billing; use App\Models\Account; +use App\Models\Offer; use App\Models\Plan; use Laravel\Cashier\SubscriptionBuilder; -use RuntimeException; final class ConfigureSubscriptionCheckout { - public const FIRST_MONTH_COUPON_METADATA_KEY = 'trypost_first_month_coupon_id'; - public const PLAN_METADATA_KEY = 'trypost_plan_slug'; public const INTERVAL_METADATA_KEY = 'trypost_interval'; - public const FIRST_MONTH_OFFER_METADATA_KEY = 'trypost_first_month_offer'; + public const OFFER_METADATA_KEY = 'trypost_offer_id'; + + public const OFFER_SLUG_METADATA_KEY = 'trypost_offer_slug'; /** * Stripe Checkout rejects subscription trials shorter than 48 hours. @@ -25,68 +25,35 @@ final class ConfigureSubscriptionCheckout public const MIN_CHECKOUT_TRIAL_DAYS = 2; /** - * @throws RuntimeException when a coupon would apply with allow_promotion_codes enabled + * The offer decides the discount, the trial and the promotion code field; + * without one the account pays the full price. A coupon wins over the + * field: Stripe rejects both on one session, never a trial with it. + * The caller resolves the offer once with offerFor(), so the coupon, the + * checkout metadata and the tracked events agree. */ - public static function apply(SubscriptionBuilder $subscription, Account $account, ?Plan $plan = null): SubscriptionBuilder + public static function apply(SubscriptionBuilder $subscription, ?Offer $offer = null): SubscriptionBuilder { - $couponId = self::firstMonthCouponId($account, $plan); - - if ($couponId !== null) { - if ((bool) config('cashier.allow_promotion_codes', false)) { - throw new RuntimeException( - 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled: ' - .'Stripe Checkout rejects discounts and allow_promotion_codes on the same session.' - ); - } - - return $subscription->withCoupon($couponId); + if (filled($offer?->trial_days)) { + $subscription->trialDays(max(self::MIN_CHECKOUT_TRIAL_DAYS, $offer->trial_days)); } - if ( - (bool) config('trypost.billing.require_card_for_trial', true) - && self::isFirstTimeSubscriber($account) - ) { - $trialDays = (int) config('cashier.trial_days'); - - if ($trialDays > 0) { - $subscription->trialDays(max(self::MIN_CHECKOUT_TRIAL_DAYS, $trialDays)); - } + if (filled($offer?->stripe_coupon_id)) { + return $subscription->withCoupon($offer->stripe_coupon_id); } - if ((bool) config('cashier.allow_promotion_codes', false)) { + if ($offer?->allow_promotion_codes === true) { $subscription->allowPromotionCodes(); } return $subscription; } - public static function offersFirstMonthCoupon(Account $account, Plan $plan): bool - { - return self::firstMonthCouponId($account, $plan) !== null; - } - - private static function firstMonthCouponId(Account $account, ?Plan $plan): ?string - { - if ($plan === null || ! (bool) config('trypost.billing.require_card_for_trial', true)) { - return null; - } - - if (! self::isFirstTimeSubscriber($account)) { - return null; - } - - $couponId = config("cashier.first_month_coupon_ids.{$plan->slug->value}"); - - if (! is_string($couponId) || $couponId === '') { - return null; - } - - return $couponId; - } - - /** Incomplete / incomplete_expired never became billable, so retries still count as first-time. */ - private static function isFirstTimeSubscriber(Account $account): bool + /** + * The offer the Welcome checkout applies. It needs the monthly plan, + * which only the Welcome checkout passes. + */ + public static function offerFor(Account $account, ?Plan $plan): ?Offer { - return ! $account->hasHadBillableSubscription(); + return filled($plan) ? $account->currentOffer() : null; } } diff --git a/app/Support/StripeSubscriptionConversion.php b/app/Support/StripeSubscriptionConversion.php index 76c4f9c977..086bf2d1a9 100644 --- a/app/Support/StripeSubscriptionConversion.php +++ b/app/Support/StripeSubscriptionConversion.php @@ -8,7 +8,6 @@ use App\Models\Account; use App\Support\Billing\ConfigureSubscriptionCheckout; use App\Support\Billing\StripeValue; -use Illuminate\Support\Carbon; final class StripeSubscriptionConversion { @@ -54,56 +53,26 @@ public static function propertiesFor(Account $account, array $payload): array } /** + * propertiesFor() plus the slug of the offer the checkout applied. + * * @param array $payload * @return array */ public static function checkoutPropertiesFor(Account $account, array $payload): array { - $properties = self::propertiesFor($account, $payload); - $firstMonthCouponId = self::firstMonthCouponId($payload); - - $properties['is_first_month_offer'] = $firstMonthCouponId !== null; - - if ($firstMonthCouponId === null) { - return $properties; - } - - $properties['first_month_coupon_id'] = $firstMonthCouponId; - $firstMonthOfferEndsAt = self::firstMonthOfferEndsAt($payload); - - if ($firstMonthOfferEndsAt !== null) { - $properties['first_month_offer_ends_at'] = $firstMonthOfferEndsAt; - } - - return $properties; - } - - /** - * @param array $payload - */ - public static function firstMonthOfferEndsAt(array $payload): ?string - { - if (self::firstMonthCouponId($payload) === null) { - return null; - } - - $currentPeriodEnd = data_get($payload, 'data.object.items.data.0.current_period_end'); - - return is_int($currentPeriodEnd) - ? Carbon::createFromTimestamp($currentPeriodEnd)->toIso8601String() - : null; + return [ + ...self::propertiesFor($account, $payload), + 'offer_slug' => self::offerSlug($payload), + ]; } /** * @param array $payload */ - private static function firstMonthCouponId(array $payload): ?string + public static function offerSlug(array $payload): ?string { - $couponId = data_get( - $payload, - 'data.object.metadata.'.ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY, - ); + $slug = data_get($payload, ['data', 'object', 'metadata', ConfigureSubscriptionCheckout::OFFER_SLUG_METADATA_KEY]); - return is_string($couponId) && $couponId !== '' ? $couponId : null; + return is_string($slug) && filled($slug) ? $slug : null; } } diff --git a/bootstrap/app.php b/bootstrap/app.php index df4c31d6b7..e30eb12c11 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Middleware\Api\LoadWorkspaceFromToken; +use App\Http\Middleware\App\CaptureOffer; use App\Http\Middleware\App\EnsureRegistrationEnabled; use App\Http\Middleware\App\HandleInertiaRequests; use App\Http\Middleware\App\SetLocale; @@ -28,6 +29,7 @@ $middleware->web(append: [ SetLocale::class, + CaptureOffer::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/cashier.php b/config/cashier.php index 48020b1218..775e0c85b0 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -135,51 +135,13 @@ | Trial Period |-------------------------------------------------------------------------- | - | Days of trial. With REQUIRE_CARD_FOR_TRIAL=true and no first-month coupon - | applied, first-time checkouts get Stripe trialDays (status trialing). - | Re-subscribers (any prior real subscription) skip trial. With - | REQUIRE_CARD_FOR_TRIAL=false, it is the generic signup trial length on - | accounts.trial_ends_at. Set to 0 to disable trials. Stripe Checkout - | rejects trials shorter than 48 hours — values of 1 are clamped to 2. + | Length of the signup trial on accounts.trial_ends_at when + | REQUIRE_CARD_FOR_TRIAL=false (no card at signup). Set to 0 to disable + | it. The Checkout trial is not configured here: it is an offer's + | trial_days (offers table). | */ 'trial_days' => env('CASHIER_TRIAL_DAYS', 8), - /* - |-------------------------------------------------------------------------- - | Paid First Month Coupons - |-------------------------------------------------------------------------- - | - | Per-plan Stripe Coupon IDs (amount_off, duration=once) so the first - | monthly invoice is $1: Socials $19 − $18, Workspaces $99 − $88. When - | the matching coupon is set and the account qualifies (card required, - | first-time, monthly price), checkout applies withCoupon and skips - | trialDays. Empty for that plan = trial_days instead. Never reuse one - | coupon on the other plan — the amounts are different. Yearly prices - | never get a coupon. Cannot be combined with allow_promotion_codes on - | the same checkout. - | - */ - - 'first_month_coupon_ids' => [ - 'socials' => env('STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID'), - 'workspaces' => env('STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID'), - ], - - /* - |-------------------------------------------------------------------------- - | Allow Promotion Codes - |-------------------------------------------------------------------------- - | - | When true and no first-month coupon is applied on the session, Stripe - | Checkout shows the redeemable promotion-code field. Defaults to false - | (SaaS recipe A). Stripe rejects a session that sets both discounts - | (coupon) and allow_promotion_codes — ConfigureSubscriptionCheckout - | throws if both would apply on the same checkout. - | - */ - - 'allow_promotion_codes' => (bool) env('CASHIER_ALLOW_PROMOTION_CODES', false), - ]; diff --git a/database/factories/OfferFactory.php b/database/factories/OfferFactory.php new file mode 100644 index 0000000000..4d2ee74edd --- /dev/null +++ b/database/factories/OfferFactory.php @@ -0,0 +1,85 @@ + + */ +class OfferFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'slug' => fake()->unique()->slug(3), + 'stripe_coupon_id' => fake()->unique()->bothify('coupon_????????'), + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'max_redemptions' => null, + 'trial_days' => null, + 'allow_promotion_codes' => false, + 'first_subscription_only' => false, + 'show_banner' => false, + ]; + } + + public function upcoming(): static + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->addDay(), + ]); + } + + public function expired(): static + { + return $this->state(fn (array $attributes): array => [ + 'starts_at' => now()->subDays(10), + 'ends_at' => now()->subDay(), + ]); + } + + public function limitedTo(int $places): static + { + return $this->state(fn (array $attributes): array => [ + 'max_redemptions' => $places, + ]); + } + + public function trial(int $days): static + { + return $this->state(fn (array $attributes): array => [ + 'stripe_coupon_id' => null, + 'trial_days' => $days, + ]); + } + + public function allowingPromotionCodes(): static + { + return $this->state(fn (array $attributes): array => [ + 'allow_promotion_codes' => true, + ]); + } + + public function withBanner(): static + { + return $this->state(fn (array $attributes): array => [ + 'show_banner' => true, + ]); + } + + public function firstSubscriptionOnly(): static + { + return $this->state(fn (array $attributes): array => [ + 'first_subscription_only' => true, + ]); + } +} diff --git a/database/migrations/2026_10_26_232641_create_offers_table.php b/database/migrations/2026_10_26_232641_create_offers_table.php new file mode 100644 index 0000000000..9b6b14ccbc --- /dev/null +++ b/database/migrations/2026_10_26_232641_create_offers_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->string('slug')->unique(); + $table->string('stripe_coupon_id')->nullable(); + $table->unsignedSmallInteger('trial_days')->nullable(); + $table->boolean('allow_promotion_codes')->default(false); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->unsignedInteger('max_redemptions')->nullable(); + $table->boolean('first_subscription_only')->default(false); + $table->boolean('show_banner')->default(false); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('offers'); + } +}; diff --git a/database/migrations/2026_10_26_232642_create_account_offer_table.php b/database/migrations/2026_10_26_232642_create_account_offer_table.php new file mode 100644 index 0000000000..02a655cb07 --- /dev/null +++ b/database/migrations/2026_10_26_232642_create_account_offer_table.php @@ -0,0 +1,28 @@ +foreignUuid('account_id')->constrained()->cascadeOnDelete(); + $table->foreignUuid('offer_id')->constrained()->cascadeOnDelete(); + $table->timestamp('claimed_at'); + $table->timestamp('redeemed_at')->nullable(); + + $table->primary(['account_id', 'offer_id']); + $table->index(['offer_id', 'redeemed_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_offer'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 5222ec9a88..a2f168c7de 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ public function run(): void $this->call([ PassportSeeder::class, PlanSeeder::class, + OfferSeeder::class, ]); } } diff --git a/database/seeders/OfferSeeder.php b/database/seeders/OfferSeeder.php new file mode 100644 index 0000000000..fcf6afaa65 --- /dev/null +++ b/database/seeders/OfferSeeder.php @@ -0,0 +1,38 @@ +isLocal()) { + return; + } + + Offer::updateOrCreate( + ['slug' => 'free-trial-7-days'], + ['starts_at' => now(), 'trial_days' => 8, 'first_subscription_only' => true], + ); + + Offer::updateOrCreate( + ['slug' => 'bf26'], + ['starts_at' => now(), 'stripe_coupon_id' => 'TRYPOST_TEST_BF_50_3M', 'show_banner' => true], + ); + + Offer::updateOrCreate( + ['slug' => 'first-month'], + ['starts_at' => now(), 'stripe_coupon_id' => 'SOCIALS_18USD', 'first_subscription_only' => true], + ); + } +} diff --git a/lang/ar/billing.php b/lang/ar/billing.php index 431f07977b..1fd909cc1b 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'فوترة شهرية', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'سنوي', 'save_two_months' => 'شهران مجانًا', 'per_month' => '/شهر', + 'per_year' => '/سنة', 'workspaces_one' => 'مساحة عمل واحدة', 'workspaces_unlimited' => 'مساحات عمل غير محدودة', 'workspaces_tooltip' => 'مساحة العمل هي علامة تجارية أو عميل واحد، منفصلة عن البقية: بحساباتها الاجتماعية وتوقيعاتها وتصنيفاتها وتحليلاتها وصلاحيات أعضائها واتصال MCP الخاص بها.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'نشطة', + 'trial' => 'تجربة مجانية', + 'cancelling' => 'ملغاة', + 'past_due' => 'متأخرة', + ], 'change' => 'تغيير الخطة', 'price_monthly' => ':price/شهر', 'price_yearly' => ':price/سنة', 'trial_until' => 'فترة تجريبية حتى :date', - 'renews' => 'يتجدد تلقائيًا', 'cancelled' => 'تم الإلغاء، ينتهي في :date', - 'workspaces_usage' => 'مساحات العمل: :count من :limit', + 'discount_until' => 'خصم :discount حتى :date', + 'discount_forever' => 'خصم :discount على كل دفعة', + 'discount_once' => 'خصم :discount على دفعتك القادمة', ], 'subscription' => [ - 'title' => 'طريقة الدفع', + 'title' => 'طرق الدفع', + 'default' => 'افتراضية', 'description' => 'حدّث بطاقتك أو بيانات الفوترة على Stripe.', 'no_payment_method' => 'لا توجد طريقة دفع مسجّلة بعد.', 'expires_on' => 'تنتهي في :month/:year', 'manage_stripe' => 'الإدارة عبر Stripe', ], + 'tabs' => [ + 'plan' => 'الخطة', + 'invoices' => 'الفواتير', + ], + 'invoices' => [ - 'title' => 'الفواتير', - 'description' => 'نزّل فواتيرك السابقة.', + 'columns' => [ + 'date' => 'التاريخ', + 'amount' => 'المبلغ', + 'status' => 'الحالة', + ], + 'empty_title' => 'لا توجد فواتير بعد', + 'empty_description' => 'ستظهر فواتيرك هنا بعد أول دفعة.', 'paid' => 'مدفوعة', 'download' => 'تنزيل الفاتورة', ], diff --git a/lang/ar/offers.php b/lang/ar/offers.php new file mode 100644 index 0000000000..aa7155a81f --- /dev/null +++ b/lang/ar/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'الجمعة السوداء', + 'headline' => 'خصم 50% على أول 3 أشهر', + 'price' => '$9.50', + 'unit' => '/شهر', + 'period' => 'لأول 3 أشهر', + ], + 'first_month' => [ + 'name' => 'الشهر الأول', + 'headline' => 'شهرك الأول مقابل $1', + 'price' => '$1', + 'period' => 'للشهر الأول', + ], + 'free_trial_7_days' => [ + 'price' => 'مجانًا', + 'period' => 'لمدة 7 أيام', + ], +]; diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index a9178e0a5c..1ebf895458 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'فعّل حسابك', 'plan_description' => 'اربط حساباتك وجدول أول منشور لك اليوم.', - 'plan_first_month' => 'للشهر الأول', 'plan_terms' => 'ثم :price/شهر. ألغِ في أي وقت.', - 'plan_per_month' => 'شهريًا', + 'plan_start_trial' => 'جرّب مجانًا', 'plan_cancel_anytime' => 'ألغِ في أي وقت.', 'mcp' => [ 'title' => 'أدِر حساباتك الاجتماعية من الدردشة', diff --git a/lang/de/billing.php b/lang/de/billing.php index 36a9e3b8aa..2ab1df0fc5 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -14,7 +14,6 @@ 'subscribe' => [ 'billed_monthly' => 'Monatlich abgerechnet', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -26,6 +25,7 @@ 'yearly' => 'Jährlich', 'save_two_months' => '2 Monate gratis', 'per_month' => '/Monat', + 'per_year' => '/Jahr', 'workspaces_one' => 'Ein Workspace', 'workspaces_unlimited' => 'Unbegrenzte Workspaces', 'workspaces_tooltip' => 'Ein Workspace ist eine Marke oder ein Kunde, getrennt von allen anderen: mit eigenen Social-Media-Konten, Signaturen, Labels, Analytics, Mitgliederrechten und MCP-Verbindung.', @@ -63,26 +63,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Aktiv', + 'trial' => 'Kostenloser Test', + 'cancelling' => 'Gekündigt', + 'past_due' => 'Überfällig', + ], 'change' => 'Tarif ändern', 'price_monthly' => ':price/Monat', 'price_yearly' => ':price/Jahr', 'trial_until' => 'Testphase bis :date', - 'renews' => 'Verlängert sich automatisch', 'cancelled' => 'Gekündigt, endet am :date', - 'workspaces_usage' => 'Workspaces: :count von :limit', + 'discount_until' => ':discount Rabatt bis :date', + 'discount_forever' => ':discount Rabatt auf jede Zahlung', + 'discount_once' => ':discount Rabatt auf deine nächste Zahlung', ], 'subscription' => [ - 'title' => 'Zahlungsmethode', + 'title' => 'Zahlungsmethoden', + 'default' => 'Standard', 'description' => 'Aktualisiere deine Karte oder Rechnungsdaten bei Stripe.', 'no_payment_method' => 'Noch keine Zahlungsmethode hinterlegt.', 'expires_on' => 'Läuft ab :month/:year', 'manage_stripe' => 'Bei Stripe verwalten', ], + 'tabs' => [ + 'plan' => 'Tarif', + 'invoices' => 'Rechnungen', + ], + 'invoices' => [ - 'title' => 'Rechnungen', - 'description' => 'Lade deine bisherigen Rechnungen herunter.', + 'columns' => [ + 'date' => 'Datum', + 'amount' => 'Betrag', + 'status' => 'Status', + ], + 'empty_title' => 'Noch keine Rechnungen', + 'empty_description' => 'Deine Rechnungen erscheinen hier nach deiner ersten Zahlung.', 'paid' => 'Bezahlt', 'download' => 'Rechnung herunterladen', ], diff --git a/lang/de/offers.php b/lang/de/offers.php new file mode 100644 index 0000000000..abab82eda1 --- /dev/null +++ b/lang/de/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50 % Rabatt in den ersten 3 Monaten', + 'price' => '$9.50', + 'unit' => '/Monat', + 'period' => 'in den ersten 3 Monaten', + ], + 'first_month' => [ + 'name' => 'Erster Monat', + 'headline' => 'Dein erster Monat für $1', + 'price' => '$1', + 'period' => 'im ersten Monat', + ], + 'free_trial_7_days' => [ + 'price' => 'Kostenlos', + 'period' => 'für 7 Tage', + ], +]; diff --git a/lang/de/welcome.php b/lang/de/welcome.php index 38c55cefaf..e3714df62d 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Aktiviere dein Konto', 'plan_description' => 'Verbinde deine Kanäle und plane noch heute deinen ersten Post.', - 'plan_first_month' => 'im ersten Monat', 'plan_terms' => 'Danach :price/Monat. Jederzeit kündbar.', - 'plan_per_month' => 'pro Monat', + 'plan_start_trial' => 'Kostenlos testen', 'plan_cancel_anytime' => 'Jederzeit kündbar.', 'mcp' => [ 'title' => 'Social Media per Chat steuern', diff --git a/lang/el/billing.php b/lang/el/billing.php index c6ecf655a3..6ad7811f68 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Μηνιαία χρέωση', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Ετήσια', 'save_two_months' => '2 μήνες δωρεάν', 'per_month' => '/μήνα', + 'per_year' => '/έτος', 'workspaces_one' => 'Ένα workspace', 'workspaces_unlimited' => 'Απεριόριστα workspaces', 'workspaces_tooltip' => 'Ένα workspace είναι ένα brand ή ένας πελάτης, ξεχωριστά από τα υπόλοιπα: με δικούς του λογαριασμούς social, υπογραφές, ετικέτες, analytics, δικαιώματα μελών και σύνδεση MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Ενεργό', + 'trial' => 'Δωρεάν δοκιμή', + 'cancelling' => 'Ακυρώθηκε', + 'past_due' => 'Ληξιπρόθεσμο', + ], 'change' => 'Αλλαγή πλάνου', 'price_monthly' => ':price/μήνα', 'price_yearly' => ':price/έτος', 'trial_until' => 'Δοκιμαστική περίοδος έως :date', - 'renews' => 'Ανανεώνεται αυτόματα', 'cancelled' => 'Ακυρώθηκε, λήγει στις :date', - 'workspaces_usage' => 'Workspaces: :count από :limit', + 'discount_until' => 'Έκπτωση :discount έως :date', + 'discount_forever' => 'Έκπτωση :discount σε κάθε πληρωμή', + 'discount_once' => 'Έκπτωση :discount στην επόμενη πληρωμή', ], 'subscription' => [ - 'title' => 'Μέθοδος πληρωμής', + 'title' => 'Μέθοδοι πληρωμής', + 'default' => 'Προεπιλογή', 'description' => 'Ενημερώστε την κάρτα ή τα στοιχεία χρέωσης στο Stripe.', 'no_payment_method' => 'Δεν υπάρχει καταχωρημένη μέθοδος πληρωμής ακόμη.', 'expires_on' => 'Λήγει :month/:year', 'manage_stripe' => 'Διαχείριση στο Stripe', ], + 'tabs' => [ + 'plan' => 'Πλάνο', + 'invoices' => 'Τιμολόγια', + ], + 'invoices' => [ - 'title' => 'Τιμολόγια', - 'description' => 'Κατεβάστε τα προηγούμενα τιμολόγιά σας.', + 'columns' => [ + 'date' => 'Ημερομηνία', + 'amount' => 'Ποσό', + 'status' => 'Κατάσταση', + ], + 'empty_title' => 'Δεν υπάρχουν ακόμη τιμολόγια', + 'empty_description' => 'Τα τιμολόγιά σας θα εμφανίζονται εδώ μετά την πρώτη σας πληρωμή.', 'paid' => 'Πληρωμένο', 'download' => 'Λήψη τιμολογίου', ], diff --git a/lang/el/offers.php b/lang/el/offers.php new file mode 100644 index 0000000000..998fda24de --- /dev/null +++ b/lang/el/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => 'Έκπτωση 50% για τους πρώτους 3 μήνες', + 'price' => '$9.50', + 'unit' => '/μήνα', + 'period' => 'για τους πρώτους 3 μήνες', + ], + 'first_month' => [ + 'name' => 'Πρώτος μήνας', + 'headline' => 'Ο πρώτος σας μήνας με $1', + 'price' => '$1', + 'period' => 'για τον πρώτο μήνα', + ], + 'free_trial_7_days' => [ + 'price' => 'Δωρεάν', + 'period' => 'για 7 ημέρες', + ], +]; diff --git a/lang/el/welcome.php b/lang/el/welcome.php index 515353d7d7..bd2aa17120 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Ενεργοποιήστε τον λογαριασμό σας', 'plan_description' => 'Συνδέστε τα κανάλια σας και προγραμματίστε την πρώτη σας ανάρτηση σήμερα.', - 'plan_first_month' => 'για τον πρώτο μήνα', 'plan_terms' => 'Μετά :price/μήνα. Ακύρωση οποτεδήποτε.', - 'plan_per_month' => 'τον μήνα', + 'plan_start_trial' => 'Δωρεάν δοκιμή', 'plan_cancel_anytime' => 'Ακύρωση οποτεδήποτε.', 'mcp' => [ 'title' => 'Διαχειριστείτε τα social μέσω chat', diff --git a/lang/en/billing.php b/lang/en/billing.php index 10fea5dda2..6e1e4a73fd 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Billed monthly', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Yearly', 'save_two_months' => '2 months free', 'per_month' => '/month', + 'per_year' => '/year', 'workspaces_one' => 'One workspace', 'workspaces_unlimited' => 'Unlimited workspaces', 'workspaces_tooltip' => 'A workspace is one brand or client, kept separate from the rest: its own social accounts, signatures, labels, analytics, member permissions, and MCP connection.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Active', + 'trial' => 'Free trial', + 'cancelling' => 'Cancelled', + 'past_due' => 'Past due', + ], 'change' => 'Change plan', 'price_monthly' => ':price/month', 'price_yearly' => ':price/year', 'trial_until' => 'Trial until :date', - 'renews' => 'Renews automatically', 'cancelled' => 'Cancelled, ends on :date', - 'workspaces_usage' => 'Workspaces: :count of :limit', + 'discount_until' => ':discount off until :date', + 'discount_forever' => ':discount off on every payment', + 'discount_once' => ':discount off your next payment', ], 'subscription' => [ - 'title' => 'Payment method', + 'title' => 'Payment methods', + 'default' => 'Default', 'description' => 'Update your card or billing details on Stripe.', 'no_payment_method' => 'No payment method on file yet.', 'expires_on' => 'Expires :month/:year', 'manage_stripe' => 'Manage on Stripe', ], + 'tabs' => [ + 'plan' => 'Plan', + 'invoices' => 'Invoices', + ], + 'invoices' => [ - 'title' => 'Invoices', - 'description' => 'Download your past invoices.', + 'columns' => [ + 'date' => 'Date', + 'amount' => 'Amount', + 'status' => 'Status', + ], + 'empty_title' => 'No invoices yet', + 'empty_description' => 'Your invoices show up here after your first payment.', 'paid' => 'Paid', 'download' => 'Download invoice', ], diff --git a/lang/en/offers.php b/lang/en/offers.php new file mode 100644 index 0000000000..d2efbc8825 --- /dev/null +++ b/lang/en/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% off your first 3 months', + 'price' => '$9.50', + 'unit' => '/month', + 'period' => 'for your first 3 months', + ], + 'first_month' => [ + 'name' => 'First month', + 'headline' => 'Your first month for $1', + 'price' => '$1', + 'period' => 'for your first month', + ], + 'free_trial_7_days' => [ + 'price' => 'Free', + 'period' => 'for 7 days', + ], +]; diff --git a/lang/en/welcome.php b/lang/en/welcome.php index 3737cd2db4..cce41f39ca 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Activate your account', 'plan_description' => 'Connect your channels and schedule your first post today.', - 'plan_first_month' => 'for your first month', 'plan_terms' => 'Then :price/month. Cancel anytime.', - 'plan_per_month' => 'per month', + 'plan_start_trial' => 'Start free trial', 'plan_cancel_anytime' => 'Cancel anytime.', 'mcp' => [ 'title' => 'Run your social media from a chat', diff --git a/lang/es/billing.php b/lang/es/billing.php index 3ea50e8207..82dc377c21 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Facturado mensualmente', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Anual', 'save_two_months' => '2 meses gratis', 'per_month' => '/mes', + 'per_year' => '/año', 'workspaces_one' => 'Un workspace', 'workspaces_unlimited' => 'Workspaces ilimitados', 'workspaces_tooltip' => 'Un workspace es una marca o cliente, separado del resto: con sus propias redes sociales, firmas, etiquetas, analíticas, permisos de miembros y conexión MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Activo', + 'trial' => 'Prueba gratis', + 'cancelling' => 'Cancelado', + 'past_due' => 'Vencido', + ], 'change' => 'Cambiar plan', 'price_monthly' => ':price/mes', 'price_yearly' => ':price/año', 'trial_until' => 'Prueba hasta el :date', - 'renews' => 'Se renueva automáticamente', 'cancelled' => 'Cancelado, termina el :date', - 'workspaces_usage' => 'Workspaces: :count de :limit', + 'discount_until' => ':discount de descuento hasta el :date', + 'discount_forever' => ':discount de descuento en todos los pagos', + 'discount_once' => ':discount de descuento en tu próximo pago', ], 'subscription' => [ - 'title' => 'Método de pago', + 'title' => 'Métodos de pago', + 'default' => 'Predeterminado', 'description' => 'Actualiza tu tarjeta o datos de facturación en Stripe.', 'no_payment_method' => 'Aún no hay método de pago registrado.', 'expires_on' => 'Vence el :month/:year', 'manage_stripe' => 'Gestionar en Stripe', ], + 'tabs' => [ + 'plan' => 'Plan', + 'invoices' => 'Facturas', + ], + 'invoices' => [ - 'title' => 'Facturas', - 'description' => 'Descarga tus facturas anteriores.', + 'columns' => [ + 'date' => 'Fecha', + 'amount' => 'Importe', + 'status' => 'Estado', + ], + 'empty_title' => 'Aún no hay facturas', + 'empty_description' => 'Tus facturas aparecerán aquí después de tu primer pago.', 'paid' => 'Pagado', 'download' => 'Descargar factura', ], diff --git a/lang/es/offers.php b/lang/es/offers.php new file mode 100644 index 0000000000..eec229aa31 --- /dev/null +++ b/lang/es/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% de descuento los primeros 3 meses', + 'price' => '$9.50', + 'unit' => '/mes', + 'period' => 'los primeros 3 meses', + ], + 'first_month' => [ + 'name' => 'Primer mes', + 'headline' => 'Tu primer mes por $1', + 'price' => '$1', + 'period' => 'el primer mes', + ], + 'free_trial_7_days' => [ + 'price' => 'Gratis', + 'period' => 'durante 7 días', + ], +]; diff --git a/lang/es/welcome.php b/lang/es/welcome.php index 4193c24169..28eadb1cc0 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Activa tu cuenta', 'plan_description' => 'Conecta tus redes y programa tu primer post hoy.', - 'plan_first_month' => 'el primer mes', 'plan_terms' => 'Luego, :price/mes. Cancela cuando quieras.', - 'plan_per_month' => 'al mes', + 'plan_start_trial' => 'Probar gratis', 'plan_cancel_anytime' => 'Cancela cuando quieras.', 'mcp' => [ 'title' => 'Gestiona tus redes desde el chat', diff --git a/lang/fr/billing.php b/lang/fr/billing.php index 413571ee67..e5c05e0b76 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Facturé mensuellement', 'prices' => [ - 'first_month' => '1 $', 'socials' => ['monthly' => '19 $', 'yearly_per_month' => '15,83 $', 'yearly' => '190 $'], 'workspaces' => ['monthly' => '99 $', 'yearly_per_month' => '82,50 $', 'yearly' => '990 $'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Annuel', 'save_two_months' => '2 mois offerts', 'per_month' => '/mois', + 'per_year' => '/an', 'workspaces_one' => 'Un espace de travail', 'workspaces_unlimited' => 'Espaces de travail illimités', 'workspaces_tooltip' => 'Un espace de travail correspond à une marque ou un client, séparé des autres : ses propres réseaux sociaux, signatures, libellés, statistiques, permissions des membres et connexion MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Active', + 'trial' => 'Essai gratuit', + 'cancelling' => 'Annulée', + 'past_due' => 'En retard', + ], 'change' => 'Changer d\'offre', 'price_monthly' => ':price/mois', 'price_yearly' => ':price/an', 'trial_until' => 'Essai jusqu\'au :date', - 'renews' => 'Renouvellement automatique', 'cancelled' => 'Annulé, se termine le :date', - 'workspaces_usage' => 'Workspaces : :count sur :limit', + 'discount_until' => ':discount de réduction jusqu\'au :date', + 'discount_forever' => ':discount de réduction sur chaque paiement', + 'discount_once' => ':discount de réduction sur votre prochain paiement', ], 'subscription' => [ - 'title' => 'Moyen de paiement', + 'title' => 'Moyens de paiement', + 'default' => 'Par défaut', 'description' => 'Mettez à jour votre carte ou vos informations de facturation sur Stripe.', 'no_payment_method' => 'Aucun moyen de paiement enregistré pour le moment.', 'expires_on' => 'Expire le :month/:year', 'manage_stripe' => 'Gérer sur Stripe', ], + 'tabs' => [ + 'plan' => 'Offre', + 'invoices' => 'Factures', + ], + 'invoices' => [ - 'title' => 'Factures', - 'description' => 'Téléchargez vos factures passées.', + 'columns' => [ + 'date' => 'Date', + 'amount' => 'Montant', + 'status' => 'Statut', + ], + 'empty_title' => 'Aucune facture pour l\'instant', + 'empty_description' => 'Vos factures apparaîtront ici après votre premier paiement.', 'paid' => 'Payée', 'download' => 'Télécharger la facture', ], diff --git a/lang/fr/offers.php b/lang/fr/offers.php new file mode 100644 index 0000000000..2cadb49d1d --- /dev/null +++ b/lang/fr/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50 % de réduction les 3 premiers mois', + 'price' => '9,50 $', + 'unit' => '/mois', + 'period' => 'pour les 3 premiers mois', + ], + 'first_month' => [ + 'name' => 'Premier mois', + 'headline' => 'Votre premier mois pour 1 $', + 'price' => '1 $', + 'period' => 'pour le premier mois', + ], + 'free_trial_7_days' => [ + 'price' => 'Gratuit', + 'period' => 'pendant 7 jours', + ], +]; diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index 9accff0430..dc464dc119 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Activez votre compte', 'plan_description' => 'Connectez vos réseaux et programmez votre premier post dès aujourd’hui.', - 'plan_first_month' => 'pour le premier mois', 'plan_terms' => 'Puis :price/mois. Annulez à tout moment.', - 'plan_per_month' => 'par mois', + 'plan_start_trial' => 'Essai gratuit', 'plan_cancel_anytime' => 'Annulez à tout moment.', 'mcp' => [ 'title' => 'Gérez vos réseaux par chat', diff --git a/lang/it/billing.php b/lang/it/billing.php index 61a74e56c3..091066b1c9 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Fatturazione mensile', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Annuale', 'save_two_months' => '2 mesi gratis', 'per_month' => '/mese', + 'per_year' => '/anno', 'workspaces_one' => 'Un workspace', 'workspaces_unlimited' => 'Workspace illimitati', 'workspaces_tooltip' => 'Un workspace è un brand o un cliente, separato dagli altri: con i propri account social, firme, etichette, analytics, permessi dei membri e connessione MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Attivo', + 'trial' => 'Prova gratuita', + 'cancelling' => 'Annullato', + 'past_due' => 'Scaduto', + ], 'change' => 'Cambia piano', 'price_monthly' => ':price/mese', 'price_yearly' => ':price/anno', 'trial_until' => 'Prova fino al :date', - 'renews' => 'Si rinnova automaticamente', 'cancelled' => 'Annullato, termina il :date', - 'workspaces_usage' => 'Workspace: :count di :limit', + 'discount_until' => ':discount di sconto fino al :date', + 'discount_forever' => ':discount di sconto su ogni pagamento', + 'discount_once' => ':discount di sconto sul prossimo pagamento', ], 'subscription' => [ - 'title' => 'Metodo di pagamento', + 'title' => 'Metodi di pagamento', + 'default' => 'Predefinito', 'description' => 'Aggiorna la carta o i dati di fatturazione su Stripe.', 'no_payment_method' => 'Nessun metodo di pagamento ancora registrato.', 'expires_on' => 'Scade il :month/:year', 'manage_stripe' => 'Gestisci su Stripe', ], + 'tabs' => [ + 'plan' => 'Piano', + 'invoices' => 'Fatture', + ], + 'invoices' => [ - 'title' => 'Fatture', - 'description' => 'Scarica le tue fatture passate.', + 'columns' => [ + 'date' => 'Data', + 'amount' => 'Importo', + 'status' => 'Stato', + ], + 'empty_title' => 'Ancora nessuna fattura', + 'empty_description' => 'Le tue fatture appariranno qui dopo il primo pagamento.', 'paid' => 'Pagata', 'download' => 'Scarica fattura', ], diff --git a/lang/it/offers.php b/lang/it/offers.php new file mode 100644 index 0000000000..ef26434373 --- /dev/null +++ b/lang/it/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% di sconto per i primi 3 mesi', + 'price' => '$9.50', + 'unit' => '/mese', + 'period' => 'per i primi 3 mesi', + ], + 'first_month' => [ + 'name' => 'Primo mese', + 'headline' => 'Il tuo primo mese a $1', + 'price' => '$1', + 'period' => 'per il primo mese', + ], + 'free_trial_7_days' => [ + 'price' => 'Gratis', + 'period' => 'per 7 giorni', + ], +]; diff --git a/lang/it/welcome.php b/lang/it/welcome.php index 47dd97ca87..d98cce1cd0 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Attiva il tuo account', 'plan_description' => 'Collega i tuoi canali e programma il tuo primo post oggi.', - 'plan_first_month' => 'per il primo mese', 'plan_terms' => 'Poi :price/mese. Disdici quando vuoi.', - 'plan_per_month' => 'al mese', + 'plan_start_trial' => 'Prova gratis', 'plan_cancel_anytime' => 'Disdici quando vuoi.', 'mcp' => [ 'title' => 'Gestisci i social dalla chat', diff --git a/lang/ja/billing.php b/lang/ja/billing.php index 4dbf006be6..e3e4108ddc 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => '月払い', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => '年額', 'save_two_months' => '2か月分無料', 'per_month' => '/月', + 'per_year' => '/年', 'workspaces_one' => 'ワークスペース 1 つ', 'workspaces_unlimited' => '無制限のワークスペース', 'workspaces_tooltip' => 'ワークスペースは 1 つのブランドや顧客ごとの独立した領域です。SNS アカウント、署名、ラベル、アナリティクス、メンバー権限、MCP 接続をそれぞれ個別に持ちます。', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => '有効', + 'trial' => '無料トライアル', + 'cancelling' => '解約済み', + 'past_due' => '延滞', + ], 'change' => 'プランを変更', 'price_monthly' => ':price/月', 'price_yearly' => ':price/年', 'trial_until' => ':dateまでトライアル', - 'renews' => '自動更新', 'cancelled' => '解約済み、:dateに終了', - 'workspaces_usage' => 'ワークスペース: :count / :limit', + 'discount_until' => ':dateまで:discount割引', + 'discount_forever' => '毎回の支払いで:discount割引', + 'discount_once' => '次回の支払いで:discount割引', ], 'subscription' => [ 'title' => 'お支払い方法', + 'default' => 'デフォルト', 'description' => 'カードや請求情報は Stripe で更新できます。', 'no_payment_method' => 'まだ登録されたお支払い方法がありません。', 'expires_on' => '有効期限 :month/:year', 'manage_stripe' => 'Stripe で管理', ], + 'tabs' => [ + 'plan' => 'プラン', + 'invoices' => '請求書', + ], + 'invoices' => [ - 'title' => '請求書', - 'description' => '過去の請求書をダウンロードできます。', + 'columns' => [ + 'date' => '日付', + 'amount' => '金額', + 'status' => 'ステータス', + ], + 'empty_title' => '請求書はまだありません', + 'empty_description' => '初回のお支払い後、請求書がここに表示されます。', 'paid' => '支払い済み', 'download' => '請求書をダウンロード', ], diff --git a/lang/ja/offers.php b/lang/ja/offers.php new file mode 100644 index 0000000000..9d2d832043 --- /dev/null +++ b/lang/ja/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'ブラックフライデー', + 'headline' => '最初の3か月50%オフ', + 'price' => '$9.50', + 'unit' => '/月', + 'period' => '最初の3か月', + ], + 'first_month' => [ + 'name' => '初月', + 'headline' => '初月は$1', + 'price' => '$1', + 'period' => '初月の料金', + ], + 'free_trial_7_days' => [ + 'price' => '無料', + 'period' => '7日間', + ], +]; diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index c6973a89a5..6d391cc814 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'アカウントを有効にしましょう', 'plan_description' => 'チャンネルを連携して、今日最初の投稿を予約しましょう。', - 'plan_first_month' => '初月の料金', 'plan_terms' => 'その後は:price/月。いつでも解約できます。', - 'plan_per_month' => '毎月', + 'plan_start_trial' => '無料で試す', 'plan_cancel_anytime' => 'いつでも解約できます。', 'mcp' => [ 'title' => 'AIとの会話でSNSを運用', diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 13652ad25f..334eaa96ed 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => '월간 결제', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => '연간', 'save_two_months' => '2개월 무료', 'per_month' => '/월', + 'per_year' => '/년', 'workspaces_one' => '워크스페이스 하나', 'workspaces_unlimited' => '무제한 워크스페이스', 'workspaces_tooltip' => '워크스페이스는 브랜드나 고객 하나를 나머지와 분리한 공간입니다. 소셜 계정, 서명, 라벨, 분석, 멤버 권한, MCP 연결을 각각 따로 가집니다.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => '활성', + 'trial' => '무료 체험', + 'cancelling' => '취소됨', + 'past_due' => '연체', + ], 'change' => '요금제 변경', 'price_monthly' => ':price/월', 'price_yearly' => ':price/년', 'trial_until' => ':date까지 체험', - 'renews' => '자동 갱신', 'cancelled' => '취소됨, :date에 종료', - 'workspaces_usage' => '워크스페이스: :count / :limit', + 'discount_until' => ':date까지 :discount 할인', + 'discount_forever' => '매 결제 시 :discount 할인', + 'discount_once' => '다음 결제 시 :discount 할인', ], 'subscription' => [ 'title' => '결제 수단', + 'default' => '기본', 'description' => '카드나 청구 정보는 Stripe에서 업데이트하세요.', 'no_payment_method' => '아직 등록된 결제 수단이 없습니다.', 'expires_on' => ':month/:year 만료', 'manage_stripe' => 'Stripe에서 관리', ], + 'tabs' => [ + 'plan' => '요금제', + 'invoices' => '청구서', + ], + 'invoices' => [ - 'title' => '청구서', - 'description' => '지난 청구서를 다운로드하세요.', + 'columns' => [ + 'date' => '날짜', + 'amount' => '금액', + 'status' => '상태', + ], + 'empty_title' => '아직 청구서가 없습니다', + 'empty_description' => '첫 결제 후 청구서가 여기에 표시됩니다.', 'paid' => '결제 완료', 'download' => '인보이스 다운로드', ], diff --git a/lang/ko/offers.php b/lang/ko/offers.php new file mode 100644 index 0000000000..6386cd3dc8 --- /dev/null +++ b/lang/ko/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => '블랙 프라이데이', + 'headline' => '첫 3개월 50% 할인', + 'price' => '$9.50', + 'unit' => '/월', + 'period' => '첫 3개월', + ], + 'first_month' => [ + 'name' => '첫 달', + 'headline' => '첫 달 $1', + 'price' => '$1', + 'period' => '첫 달 요금', + ], + 'free_trial_7_days' => [ + 'price' => '무료', + 'period' => '7일 동안', + ], +]; diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index 2901f0ae58..818002c96d 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => '계정을 활성화하세요', 'plan_description' => '채널을 연결하고 오늘 첫 게시물을 예약하세요.', - 'plan_first_month' => '첫 달 요금', 'plan_terms' => '이후 :price/월. 언제든지 해지 가능.', - 'plan_per_month' => '매월', + 'plan_start_trial' => '무료 체험 시작', 'plan_cancel_anytime' => '언제든지 해지 가능.', 'mcp' => [ 'title' => 'AI와 대화하며 SNS를 관리하세요', diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 313c3ece6c..5c26ab9f7c 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Maandelijks gefactureerd', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Jaarlijks', 'save_two_months' => '2 maanden gratis', 'per_month' => '/maand', + 'per_year' => '/jaar', 'workspaces_one' => 'Eén workspace', 'workspaces_unlimited' => 'Onbeperkte workspaces', 'workspaces_tooltip' => 'Een workspace is één merk of klant, gescheiden van de rest: met eigen social-media-accounts, handtekeningen, labels, analytics, ledenrechten en MCP-verbinding.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Actief', + 'trial' => 'Gratis proefperiode', + 'cancelling' => 'Opgezegd', + 'past_due' => 'Achterstallig', + ], 'change' => 'Plan wijzigen', 'price_monthly' => ':price/maand', 'price_yearly' => ':price/jaar', 'trial_until' => 'Proefperiode tot :date', - 'renews' => 'Wordt automatisch verlengd', 'cancelled' => 'Opgezegd, eindigt op :date', - 'workspaces_usage' => 'Workspaces: :count van :limit', + 'discount_until' => ':discount korting tot :date', + 'discount_forever' => ':discount korting op elke betaling', + 'discount_once' => ':discount korting op je volgende betaling', ], 'subscription' => [ - 'title' => 'Betaalmethode', + 'title' => 'Betaalmethoden', + 'default' => 'Standaard', 'description' => 'Werk je kaart of factuurgegevens bij in Stripe.', 'no_payment_method' => 'Nog geen betaalmethode geregistreerd.', 'expires_on' => 'Verloopt :month/:year', 'manage_stripe' => 'Beheren op Stripe', ], + 'tabs' => [ + 'plan' => 'Plan', + 'invoices' => 'Facturen', + ], + 'invoices' => [ - 'title' => 'Facturen', - 'description' => 'Download je eerdere facturen.', + 'columns' => [ + 'date' => 'Datum', + 'amount' => 'Bedrag', + 'status' => 'Status', + ], + 'empty_title' => 'Nog geen facturen', + 'empty_description' => 'Je facturen verschijnen hier na je eerste betaling.', 'paid' => 'Betaald', 'download' => 'Factuur downloaden', ], diff --git a/lang/nl/offers.php b/lang/nl/offers.php new file mode 100644 index 0000000000..0790d21390 --- /dev/null +++ b/lang/nl/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% korting de eerste 3 maanden', + 'price' => '$9.50', + 'unit' => '/maand', + 'period' => 'voor de eerste 3 maanden', + ], + 'first_month' => [ + 'name' => 'Eerste maand', + 'headline' => 'Je eerste maand voor $1', + 'price' => '$1', + 'period' => 'voor de eerste maand', + ], + 'free_trial_7_days' => [ + 'price' => 'Gratis', + 'period' => 'voor 7 dagen', + ], +]; diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index 3e26c19eef..3a77ac001a 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Activeer je account', 'plan_description' => 'Koppel je kanalen en plan vandaag je eerste post.', - 'plan_first_month' => 'voor de eerste maand', 'plan_terms' => 'Daarna :price/maand. Altijd opzegbaar.', - 'plan_per_month' => 'per maand', + 'plan_start_trial' => 'Gratis proberen', 'plan_cancel_anytime' => 'Altijd opzegbaar.', 'mcp' => [ 'title' => 'Beheer je socials via chat', diff --git a/lang/pl/billing.php b/lang/pl/billing.php index d9dc0a9d01..98ba762b90 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Rozliczane miesięcznie', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Rocznie', 'save_two_months' => '2 miesiące gratis', 'per_month' => '/miesiąc', + 'per_year' => '/rok', 'workspaces_one' => 'Jeden workspace', 'workspaces_unlimited' => 'Nielimitowane workspace’y', 'workspaces_tooltip' => 'Workspace to jedna marka lub klient, oddzielony od pozostałych: własne konta społecznościowe, podpisy, etykiety, analityka, uprawnienia członków i połączenie MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Aktywny', + 'trial' => 'Okres próbny', + 'cancelling' => 'Anulowany', + 'past_due' => 'Zaległy', + ], 'change' => 'Zmień plan', 'price_monthly' => ':price/miesiąc', 'price_yearly' => ':price/rok', 'trial_until' => 'Okres próbny do :date', - 'renews' => 'Odnawia się automatycznie', 'cancelled' => 'Anulowano, kończy się :date', - 'workspaces_usage' => 'Workspace’y: :count z :limit', + 'discount_until' => 'Rabat :discount do :date', + 'discount_forever' => 'Rabat :discount na każdą płatność', + 'discount_once' => 'Rabat :discount na następną płatność', ], 'subscription' => [ - 'title' => 'Metoda płatności', + 'title' => 'Metody płatności', + 'default' => 'Domyślna', 'description' => 'Zaktualizuj kartę lub dane rozliczeniowe w Stripe.', 'no_payment_method' => 'Brak zapisanej metody płatności.', 'expires_on' => 'Wygasa :month/:year', 'manage_stripe' => 'Zarządzaj w Stripe', ], + 'tabs' => [ + 'plan' => 'Plan', + 'invoices' => 'Faktury', + ], + 'invoices' => [ - 'title' => 'Faktury', - 'description' => 'Pobierz swoje wcześniejsze faktury.', + 'columns' => [ + 'date' => 'Data', + 'amount' => 'Kwota', + 'status' => 'Status', + ], + 'empty_title' => 'Brak faktur', + 'empty_description' => 'Faktury pojawią się tutaj po pierwszej płatności.', 'paid' => 'Opłacona', 'download' => 'Pobierz fakturę', ], diff --git a/lang/pl/offers.php b/lang/pl/offers.php new file mode 100644 index 0000000000..f058022da5 --- /dev/null +++ b/lang/pl/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% zniżki przez pierwsze 3 miesiące', + 'price' => '$9.50', + 'unit' => '/miesiąc', + 'period' => 'przez pierwsze 3 miesiące', + ], + 'first_month' => [ + 'name' => 'Pierwszy miesiąc', + 'headline' => 'Pierwszy miesiąc za $1', + 'price' => '$1', + 'period' => 'za pierwszy miesiąc', + ], + 'free_trial_7_days' => [ + 'price' => 'Za darmo', + 'period' => 'przez 7 dni', + ], +]; diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index a8d8f07f84..4a4daafb41 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Aktywuj swoje konto', 'plan_description' => 'Połącz kanały i zaplanuj pierwszy post już dziś.', - 'plan_first_month' => 'za pierwszy miesiąc', 'plan_terms' => 'Potem :price/mies. Anuluj w dowolnym momencie.', - 'plan_per_month' => 'miesięcznie', + 'plan_start_trial' => 'Wypróbuj za darmo', 'plan_cancel_anytime' => 'Anuluj w dowolnym momencie.', 'mcp' => [ 'title' => 'Zarządzaj social mediami w czacie', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index d40a2ec821..f44b5b36f1 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Cobrança mensal', 'prices' => [ - 'first_month' => 'R$ 1', 'socials' => ['monthly' => 'R$ 99', 'yearly_per_month' => 'R$ 82,50', 'yearly' => 'R$ 990'], 'workspaces' => ['monthly' => 'R$ 499', 'yearly_per_month' => 'R$ 415,83', 'yearly' => 'R$ 4.990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Anual', 'save_two_months' => '2 meses grátis', 'per_month' => '/mês', + 'per_year' => '/ano', 'workspaces_one' => 'Um workspace', 'workspaces_unlimited' => 'Workspaces ilimitados', 'workspaces_tooltip' => 'Um workspace é uma marca ou cliente, separado dos demais: com suas próprias redes sociais, assinaturas, labels, analytics, permissões de membros e conexão MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Ativo', + 'trial' => 'Teste grátis', + 'cancelling' => 'Cancelado', + 'past_due' => 'Em atraso', + ], 'change' => 'Mudar plano', 'price_monthly' => ':price/mês', 'price_yearly' => ':price/ano', 'trial_until' => 'Trial até :date', - 'renews' => 'Renova automaticamente', 'cancelled' => 'Cancelado, termina em :date', - 'workspaces_usage' => 'Workspaces: :count de :limit', + 'discount_until' => ':discount de desconto até :date', + 'discount_forever' => ':discount de desconto em todos os pagamentos', + 'discount_once' => ':discount de desconto no próximo pagamento', ], 'subscription' => [ - 'title' => 'Método de pagamento', + 'title' => 'Métodos de pagamento', + 'default' => 'Padrão', 'description' => 'Atualize seu cartão ou dados de cobrança no Stripe.', 'no_payment_method' => 'Nenhum método de pagamento cadastrado.', 'expires_on' => 'Expira em :month/:year', 'manage_stripe' => 'Gerenciar no Stripe', ], + 'tabs' => [ + 'plan' => 'Plano', + 'invoices' => 'Faturas', + ], + 'invoices' => [ - 'title' => 'Faturas', - 'description' => 'Baixe suas faturas anteriores.', + 'columns' => [ + 'date' => 'Data', + 'amount' => 'Valor', + 'status' => 'Status', + ], + 'empty_title' => 'Nenhuma fatura ainda', + 'empty_description' => 'Suas faturas aparecem aqui depois do primeiro pagamento.', 'paid' => 'Pago', 'download' => 'Baixar fatura', ], diff --git a/lang/pt-BR/offers.php b/lang/pt-BR/offers.php new file mode 100644 index 0000000000..83035c4fe5 --- /dev/null +++ b/lang/pt-BR/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => '50% de desconto nos 3 primeiros meses', + 'price' => 'R$ 49,50', + 'unit' => '/mês', + 'period' => 'nos 3 primeiros meses', + ], + 'first_month' => [ + 'name' => 'Primeiro mês', + 'headline' => 'Seu primeiro mês por R$ 1', + 'price' => 'R$ 1', + 'period' => 'no primeiro mês', + ], + 'free_trial_7_days' => [ + 'price' => 'Grátis', + 'period' => 'por 7 dias', + ], +]; diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index b74768449f..24e3c784b8 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Ative sua conta', 'plan_description' => 'Conecte suas redes e agende seu primeiro post hoje.', - 'plan_first_month' => 'no primeiro mês', 'plan_terms' => 'Depois, :price/mês. Cancele quando quiser.', - 'plan_per_month' => 'por mês', + 'plan_start_trial' => 'Testar grátis', 'plan_cancel_anytime' => 'Cancele quando quiser.', 'mcp' => [ 'title' => 'Gerencie suas redes pelo chat', diff --git a/lang/ru/billing.php b/lang/ru/billing.php index e71de5e790..7a951da92b 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Ежемесячная оплата', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Ежегодно', 'save_two_months' => '2 месяца бесплатно', 'per_month' => '/месяц', + 'per_year' => '/год', 'workspaces_one' => 'Один workspace', 'workspaces_unlimited' => 'Неограниченное число workspace', 'workspaces_tooltip' => 'Workspace — это один бренд или клиент, отделённый от остальных: свои соцсети, подписи, метки, аналитика, права участников и подключение MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Активен', + 'trial' => 'Пробный период', + 'cancelling' => 'Отменён', + 'past_due' => 'Просрочен', + ], 'change' => 'Сменить план', 'price_monthly' => ':price/месяц', 'price_yearly' => ':price/год', 'trial_until' => 'Пробный период до :date', - 'renews' => 'Продлевается автоматически', 'cancelled' => 'Отменён, действует до :date', - 'workspaces_usage' => 'Рабочие пространства: :count из :limit', + 'discount_until' => 'Скидка :discount до :date', + 'discount_forever' => 'Скидка :discount на каждый платёж', + 'discount_once' => 'Скидка :discount на следующий платёж', ], 'subscription' => [ - 'title' => 'Способ оплаты', + 'title' => 'Способы оплаты', + 'default' => 'Основная', 'description' => 'Обновите карту или платёжные данные в Stripe.', 'no_payment_method' => 'Способ оплаты пока не добавлен.', 'expires_on' => 'Истекает :month/:year', 'manage_stripe' => 'Управлять в Stripe', ], + 'tabs' => [ + 'plan' => 'План', + 'invoices' => 'Счета', + ], + 'invoices' => [ - 'title' => 'Счета', - 'description' => 'Скачивайте прошлые счета.', + 'columns' => [ + 'date' => 'Дата', + 'amount' => 'Сумма', + 'status' => 'Статус', + ], + 'empty_title' => 'Счетов пока нет', + 'empty_description' => 'Счета появятся здесь после первой оплаты.', 'paid' => 'Оплачено', 'download' => 'Скачать счёт', ], diff --git a/lang/ru/offers.php b/lang/ru/offers.php new file mode 100644 index 0000000000..8b667f7eb2 --- /dev/null +++ b/lang/ru/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Чёрная пятница', + 'headline' => 'Скидка 50% на первые 3 месяца', + 'price' => '$9.50', + 'unit' => '/месяц', + 'period' => 'за первые 3 месяца', + ], + 'first_month' => [ + 'name' => 'Первый месяц', + 'headline' => 'Первый месяц за $1', + 'price' => '$1', + 'period' => 'за первый месяц', + ], + 'free_trial_7_days' => [ + 'price' => 'Бесплатно', + 'period' => 'на 7 дней', + ], +]; diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index b7d40d03b3..3e2228eb00 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Активируйте аккаунт', 'plan_description' => 'Подключите каналы и запланируйте первый пост уже сегодня.', - 'plan_first_month' => 'за первый месяц', 'plan_terms' => 'Затем :price/мес. Отмена в любой момент.', - 'plan_per_month' => 'в месяц', + 'plan_start_trial' => 'Начать бесплатно', 'plan_cancel_anytime' => 'Отмена в любой момент.', 'mcp' => [ 'title' => 'Ведите соцсети прямо в чате', diff --git a/lang/tr/billing.php b/lang/tr/billing.php index 74b3c4e941..038fcb241a 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -14,7 +14,6 @@ 'subscribe' => [ 'billed_monthly' => 'Aylık faturalandırılır', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -26,6 +25,7 @@ 'yearly' => 'Yıllık', 'save_two_months' => '2 ay ücretsiz', 'per_month' => '/ay', + 'per_year' => '/yıl', 'workspaces_one' => 'Bir workspace', 'workspaces_unlimited' => 'Sınırsız workspace', 'workspaces_tooltip' => 'Workspace, diğerlerinden ayrı tutulan tek bir marka veya müşteridir: kendi sosyal medya hesapları, imzaları, etiketleri, analizleri, üye izinleri ve MCP bağlantısı vardır.', @@ -63,26 +63,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Aktif', + 'trial' => 'Ücretsiz deneme', + 'cancelling' => 'İptal edildi', + 'past_due' => 'Gecikmiş', + ], 'change' => 'Planı değiştir', 'price_monthly' => ':price/ay', 'price_yearly' => ':price/yıl', 'trial_until' => ':date tarihine kadar deneme', - 'renews' => 'Otomatik olarak yenilenir', 'cancelled' => 'İptal edildi, :date tarihinde sona erer', - 'workspaces_usage' => 'Çalışma alanları: :count / :limit', + 'discount_until' => ':date tarihine kadar :discount indirim', + 'discount_forever' => 'Her ödemede :discount indirim', + 'discount_once' => 'Sonraki ödemenizde :discount indirim', ], 'subscription' => [ - 'title' => 'Ödeme yöntemi', + 'title' => 'Ödeme yöntemleri', + 'default' => 'Varsayılan', 'description' => 'Kartınızı veya fatura bilgilerinizi Stripe’da güncelleyin.', 'no_payment_method' => 'Henüz kayıtlı ödeme yöntemi yok.', 'expires_on' => 'Son kullanma: :month/:year', 'manage_stripe' => 'Stripe\'ta yönet', ], + 'tabs' => [ + 'plan' => 'Plan', + 'invoices' => 'Faturalar', + ], + 'invoices' => [ - 'title' => 'Faturalar', - 'description' => 'Geçmiş faturalarınızı indirin.', + 'columns' => [ + 'date' => 'Tarih', + 'amount' => 'Tutar', + 'status' => 'Durum', + ], + 'empty_title' => 'Henüz fatura yok', + 'empty_description' => 'Faturalarınız ilk ödemenizden sonra burada görünür.', 'paid' => 'Ödendi', 'download' => 'Faturayı indir', ], diff --git a/lang/tr/offers.php b/lang/tr/offers.php new file mode 100644 index 0000000000..5e5a198233 --- /dev/null +++ b/lang/tr/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Black Friday', + 'headline' => 'İlk 3 ayda %50 indirim', + 'price' => '$9.50', + 'unit' => '/ay', + 'period' => 'ilk 3 ay için', + ], + 'first_month' => [ + 'name' => 'İlk ay', + 'headline' => 'İlk ayın $1', + 'price' => '$1', + 'period' => 'ilk ay için', + ], + 'free_trial_7_days' => [ + 'price' => 'Ücretsiz', + 'period' => '7 gün boyunca', + ], +]; diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index 1690b068c4..0c81fa1e74 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Hesabını etkinleştir', 'plan_description' => 'Kanallarını bağla ve ilk gönderini bugün planla.', - 'plan_first_month' => 'ilk ay için', 'plan_terms' => 'Sonra :price/ay. İstediğin zaman iptal et.', - 'plan_per_month' => 'aylık', + 'plan_start_trial' => 'Ücretsiz dene', 'plan_cancel_anytime' => 'İstediğin zaman iptal et.', 'mcp' => [ 'title' => 'Sosyal medyanı sohbetle yönet', diff --git a/lang/uk/billing.php b/lang/uk/billing.php index c08f1b0332..59157fba1f 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => 'Щомісячна оплата', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => 'Щороку', 'save_two_months' => '2 місяці безкоштовно', 'per_month' => '/місяць', + 'per_year' => '/рік', 'workspaces_one' => 'Один workspace', 'workspaces_unlimited' => 'Необмежена кількість workspace', 'workspaces_tooltip' => 'Workspace — це один бренд або клієнт, відокремлений від інших: власні соцмережі, підписи, мітки, аналітика, права учасників і підключення MCP.', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => 'Активний', + 'trial' => 'Пробний період', + 'cancelling' => 'Скасовано', + 'past_due' => 'Прострочено', + ], 'change' => 'Змінити план', 'price_monthly' => ':price/місяць', 'price_yearly' => ':price/рік', 'trial_until' => 'Пробний період до :date', - 'renews' => 'Поновлюється автоматично', 'cancelled' => 'Скасовано, діє до :date', - 'workspaces_usage' => 'Робочі простори: :count з :limit', + 'discount_until' => 'Знижка :discount до :date', + 'discount_forever' => 'Знижка :discount на кожен платіж', + 'discount_once' => 'Знижка :discount на наступний платіж', ], 'subscription' => [ - 'title' => 'Спосіб оплати', + 'title' => 'Способи оплати', + 'default' => 'Основна', 'description' => 'Оновіть картку або платіжні дані в Stripe.', 'no_payment_method' => 'Спосіб оплати ще не додано.', 'expires_on' => 'Діє до :month/:year', 'manage_stripe' => 'Керувати в Stripe', ], + 'tabs' => [ + 'plan' => 'План', + 'invoices' => 'Рахунки', + ], + 'invoices' => [ - 'title' => 'Рахунки', - 'description' => 'Завантажуйте минулі рахунки.', + 'columns' => [ + 'date' => 'Дата', + 'amount' => 'Сума', + 'status' => 'Статус', + ], + 'empty_title' => 'Рахунків поки немає', + 'empty_description' => 'Рахунки з’являться тут після першої оплати.', 'paid' => 'Оплачено', 'download' => 'Завантажити рахунок', ], diff --git a/lang/uk/offers.php b/lang/uk/offers.php new file mode 100644 index 0000000000..e7d2269124 --- /dev/null +++ b/lang/uk/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => 'Чорна пʼятниця', + 'headline' => 'Знижка 50% на перші 3 місяці', + 'price' => '$9.50', + 'unit' => '/місяць', + 'period' => 'за перші 3 місяці', + ], + 'first_month' => [ + 'name' => 'Перший місяць', + 'headline' => 'Перший місяць за $1', + 'price' => '$1', + 'period' => 'за перший місяць', + ], + 'free_trial_7_days' => [ + 'price' => 'Безкоштовно', + 'period' => 'на 7 днів', + ], +]; diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index 135b45ee2b..25824952f9 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => 'Активуйте акаунт', 'plan_description' => 'Підключіть канали та заплануйте перший пост уже сьогодні.', - 'plan_first_month' => 'за перший місяць', 'plan_terms' => 'Потім :price/міс. Скасування будь-коли.', - 'plan_per_month' => 'на місяць', + 'plan_start_trial' => 'Почати безкоштовно', 'plan_cancel_anytime' => 'Скасування будь-коли.', 'mcp' => [ 'title' => 'Ведіть соцмережі просто в чаті', diff --git a/lang/zh/billing.php b/lang/zh/billing.php index 0c9e8620ff..40c64c017d 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -12,7 +12,6 @@ 'subscribe' => [ 'billed_monthly' => '按月计费', 'prices' => [ - 'first_month' => '$1', 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], @@ -24,6 +23,7 @@ 'yearly' => '按年', 'save_two_months' => '免两个月', 'per_month' => '/月', + 'per_year' => '/年', 'workspaces_one' => '一个工作区', 'workspaces_unlimited' => '无限工作区', 'workspaces_tooltip' => '一个工作区对应一个品牌或客户,与其他工作区相互隔离:拥有自己的社交账号、签名、标签、数据分析、成员权限和 MCP 连接。', @@ -61,26 +61,44 @@ ], 'current_plan' => [ + 'statuses' => [ + 'active' => '生效中', + 'trial' => '免费试用', + 'cancelling' => '已取消', + 'past_due' => '已逾期', + ], 'change' => '更改套餐', 'price_monthly' => ':price/月', 'price_yearly' => ':price/年', 'trial_until' => '试用至 :date', - 'renews' => '自动续订', 'cancelled' => '已取消,将于 :date 结束', - 'workspaces_usage' => '工作区::count / :limit', + 'discount_until' => '优惠 :discount,截至 :date', + 'discount_forever' => '每次付款优惠 :discount', + 'discount_once' => '下次付款优惠 :discount', ], 'subscription' => [ 'title' => '付款方式', + 'default' => '默认', 'description' => '在 Stripe 更新你的卡片或账单信息。', 'no_payment_method' => '尚未添加付款方式。', 'expires_on' => '有效期至 :month/:year', 'manage_stripe' => '在 Stripe 上管理', ], + 'tabs' => [ + 'plan' => '套餐', + 'invoices' => '发票', + ], + 'invoices' => [ - 'title' => '发票', - 'description' => '下载你过往的发票。', + 'columns' => [ + 'date' => '日期', + 'amount' => '金额', + 'status' => '状态', + ], + 'empty_title' => '暂无发票', + 'empty_description' => '首次付款后,你的发票会显示在这里。', 'paid' => '已支付', 'download' => '下载发票', ], diff --git a/lang/zh/offers.php b/lang/zh/offers.php new file mode 100644 index 0000000000..61adef62e9 --- /dev/null +++ b/lang/zh/offers.php @@ -0,0 +1,21 @@ + [ + 'name' => '黑色星期五', + 'headline' => '前 3 个月 5 折', + 'price' => '$9.50', + 'unit' => '/月', + 'period' => '前 3 个月', + ], + 'first_month' => [ + 'name' => '首月', + 'headline' => '首月仅需 $1', + 'price' => '$1', + 'period' => '首月价格', + ], + 'free_trial_7_days' => [ + 'price' => '免费', + 'period' => '7 天内', + ], +]; diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index dfb4d08abf..91e709225f 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -47,9 +47,8 @@ ], 'plan_title' => '激活你的账户', 'plan_description' => '连接你的账号,今天就排期第一条帖子。', - 'plan_first_month' => '首月价格', 'plan_terms' => '之后 :price/月,随时取消。', - 'plan_per_month' => '每月', + 'plan_start_trial' => '免费试用', 'plan_cancel_anytime' => '随时取消。', 'mcp' => [ 'title' => '和 AI 聊天,轻松管理社交媒体', diff --git a/public/images/card-brands/amex.svg b/public/images/card-brands/amex.svg new file mode 100755 index 0000000000..9007e03fc7 --- /dev/null +++ b/public/images/card-brands/amex.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/diners.svg b/public/images/card-brands/diners.svg new file mode 100755 index 0000000000..3f1827bf44 --- /dev/null +++ b/public/images/card-brands/diners.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/discover.svg b/public/images/card-brands/discover.svg new file mode 100755 index 0000000000..e6ee965659 --- /dev/null +++ b/public/images/card-brands/discover.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/generic.svg b/public/images/card-brands/generic.svg new file mode 100755 index 0000000000..e0dd349da5 --- /dev/null +++ b/public/images/card-brands/generic.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/jcb.svg b/public/images/card-brands/jcb.svg new file mode 100755 index 0000000000..b1143c01bf --- /dev/null +++ b/public/images/card-brands/jcb.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/mastercard.svg b/public/images/card-brands/mastercard.svg new file mode 100755 index 0000000000..73b6a4d6ba --- /dev/null +++ b/public/images/card-brands/mastercard.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/card-brands/unionpay.svg b/public/images/card-brands/unionpay.svg new file mode 100755 index 0000000000..145428d71e --- /dev/null +++ b/public/images/card-brands/unionpay.svg @@ -0,0 +1 @@ + diff --git a/public/images/card-brands/visa.svg b/public/images/card-brands/visa.svg new file mode 100755 index 0000000000..c2e790a0d9 --- /dev/null +++ b/public/images/card-brands/visa.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/resources/js/lib/cardBrands.ts b/resources/js/lib/cardBrands.ts new file mode 100644 index 0000000000..cc6990cc57 --- /dev/null +++ b/resources/js/lib/cardBrands.ts @@ -0,0 +1,28 @@ +interface CardBrand { + name: string; + logo: string; +} + +/** + * Card brands as Stripe names them (`card.brand`), with the name people know + * and its card-shaped logo. A brand without its own logo gets the generic card. + */ +const CARD_BRANDS: Record = { + amex: { name: 'American Express', logo: '/images/card-brands/amex.svg' }, + cartes_bancaires: { name: 'Cartes Bancaires', logo: '/images/card-brands/generic.svg' }, + diners: { name: 'Diners Club', logo: '/images/card-brands/diners.svg' }, + discover: { name: 'Discover', logo: '/images/card-brands/discover.svg' }, + eftpos_au: { name: 'eftpos', logo: '/images/card-brands/generic.svg' }, + interac: { name: 'Interac', logo: '/images/card-brands/generic.svg' }, + jcb: { name: 'JCB', logo: '/images/card-brands/jcb.svg' }, + link: { name: 'Link', logo: '/images/card-brands/generic.svg' }, + mastercard: { name: 'Mastercard', logo: '/images/card-brands/mastercard.svg' }, + unionpay: { name: 'UnionPay', logo: '/images/card-brands/unionpay.svg' }, + visa: { name: 'Visa', logo: '/images/card-brands/visa.svg' }, +}; + +export const cardBrand = (brand: string): CardBrand => + CARD_BRANDS[brand] ?? { + name: brand.charAt(0).toUpperCase() + brand.slice(1), + logo: '/images/card-brands/generic.svg', + }; diff --git a/resources/js/pages/auth/Login.vue b/resources/js/pages/auth/Login.vue index 6ca5b4e5b4..8545b9be42 100644 --- a/resources/js/pages/auth/Login.vue +++ b/resources/js/pages/auth/Login.vue @@ -76,6 +76,7 @@ const pageErrors = usePageErrors(); id="email" type="email" name="email" + data-testid="login-email" autofocus :tabindex="1" autocomplete="email" @@ -103,6 +104,7 @@ const pageErrors = usePageErrors(); id="password" :type="showPassword ? 'text' : 'password'" name="password" + data-testid="login-password" :tabindex="2" autocomplete="current-password" :placeholder="$t('auth.login.password')" diff --git a/resources/js/pages/settings/account/Billing.vue b/resources/js/pages/settings/account/Billing.vue index 79dd7552ac..7a41ccc0b1 100644 --- a/resources/js/pages/settings/account/Billing.vue +++ b/resources/js/pages/settings/account/Billing.vue @@ -1,12 +1,17 @@