From 1af3f7ec0f108d8518b24e5d3a1c6658ec859910 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 26 Oct 2026 20:49:21 -0300 Subject: [PATCH 01/11] Apply offer coupons from ?offer= links at the Welcome checkout An offer is a Stripe coupon tied to an account through ?offer= on any page. The Welcome plan step shows its badge and price and checks out with its coupon instead of the first-month one; the subscription.created webhook marks it redeemed. Dates and places live on the offers row only. --- AGENTS.md | 43 ++++ .../Billing/StartSubscriptionCheckout.php | 7 +- app/Actions/Offer/ClaimOffer.php | 36 ++++ app/Actions/Offer/RedeemOffer.php | 26 +++ .../Controllers/App/WelcomeController.php | 26 +++ app/Http/Middleware/App/CaptureOffer.php | 44 ++++ app/Listeners/StripeEventListener.php | 3 + app/Models/Account.php | 19 ++ app/Models/Offer.php | 72 +++++++ app/Providers/AppServiceProvider.php | 2 + .../Billing/ConfigureSubscriptionCheckout.php | 27 ++- bootstrap/app.php | 2 + database/factories/OfferFactory.php | 52 +++++ .../2026_10_26_232641_create_offers_table.php | 28 +++ ...0_26_232642_create_account_offer_table.php | 28 +++ lang/ar/offers.php | 4 + lang/ar/welcome.php | 2 + lang/de/offers.php | 4 + lang/de/welcome.php | 2 + lang/el/offers.php | 4 + lang/el/welcome.php | 2 + lang/en/offers.php | 4 + lang/en/welcome.php | 2 + lang/es/offers.php | 4 + lang/es/welcome.php | 2 + lang/fr/offers.php | 4 + lang/fr/welcome.php | 2 + lang/it/offers.php | 4 + lang/it/welcome.php | 2 + lang/ja/offers.php | 4 + lang/ja/welcome.php | 2 + lang/ko/offers.php | 4 + lang/ko/welcome.php | 2 + lang/nl/offers.php | 4 + lang/nl/welcome.php | 2 + lang/pl/offers.php | 4 + lang/pl/welcome.php | 2 + lang/pt-BR/offers.php | 4 + lang/pt-BR/welcome.php | 2 + lang/ru/offers.php | 4 + lang/ru/welcome.php | 2 + lang/tr/offers.php | 4 + lang/tr/welcome.php | 2 + lang/uk/offers.php | 4 + lang/uk/welcome.php | 2 + lang/zh/offers.php | 4 + lang/zh/welcome.php | 2 + resources/js/pages/welcome/Plan.vue | 59 +++++- tests/Browser/WelcomePlanTest.php | 31 +++ tests/Feature/Models/OfferTest.php | 76 +++++++ tests/Feature/Offer/ClaimOfferTest.php | 198 ++++++++++++++++++ tests/Feature/Offer/OfferCopyTest.php | 15 ++ tests/Feature/Offer/RedeemOfferTest.php | 72 +++++++ .../Feature/Welcome/WelcomeControllerTest.php | 60 ++++++ .../Billing/StartSubscriptionCheckoutTest.php | 46 ++++ .../ConfigureSubscriptionCheckoutTest.php | 72 +++++++ 56 files changed, 1126 insertions(+), 14 deletions(-) create mode 100644 app/Actions/Offer/ClaimOffer.php create mode 100644 app/Actions/Offer/RedeemOffer.php create mode 100644 app/Http/Middleware/App/CaptureOffer.php create mode 100644 app/Models/Offer.php create mode 100644 database/factories/OfferFactory.php create mode 100644 database/migrations/2026_10_26_232641_create_offers_table.php create mode 100644 database/migrations/2026_10_26_232642_create_account_offer_table.php create mode 100644 lang/ar/offers.php create mode 100644 lang/de/offers.php create mode 100644 lang/el/offers.php create mode 100644 lang/en/offers.php create mode 100644 lang/es/offers.php create mode 100644 lang/fr/offers.php create mode 100644 lang/it/offers.php create mode 100644 lang/ja/offers.php create mode 100644 lang/ko/offers.php create mode 100644 lang/nl/offers.php create mode 100644 lang/pl/offers.php create mode 100644 lang/pt-BR/offers.php create mode 100644 lang/ru/offers.php create mode 100644 lang/tr/offers.php create mode 100644 lang/uk/offers.php create mode 100644 lang/zh/offers.php create mode 100644 tests/Feature/Models/OfferTest.php create mode 100644 tests/Feature/Offer/ClaimOfferTest.php create mode 100644 tests/Feature/Offer/OfferCopyTest.php create mode 100644 tests/Feature/Offer/RedeemOfferTest.php diff --git a/AGENTS.md b/AGENTS.md index 6f41460d08..64e2554637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -363,6 +363,49 @@ 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 a Stripe coupon the Welcome checkout applies instead of the `$1` +first month (owner decision October 2026). It lives in `offers` (`slug`, +`stripe_coupon_id`, `starts_at` required, `ends_at` and `max_redemptions` +nullable: no end, no limit) and reaches an account through the pivot +`account_offer` (`claimed_at`, `redeemed_at`). + +- **Capture.** `CaptureOffer` (web middleware) reads `?offer=` on any page. + A guest's slug waits in the session; the first authenticated request claims it + (`ClaimOffer`), so email signup, Google, GitHub and login need no code of their + own. 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, with no + first-time-subscriber rule. 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.** `ConfigureSubscriptionCheckout::apply()`: available offer + (`Account::currentOffer()`, the latest claim) → first-month coupon → trial. An + offer skips the trial and throws with `allow_promotion_codes`, like the coupon. + Validity is checked again when checkout starts. The offer id goes in the + subscription metadata `trypost_offer_id`, and the `customer.subscription.created` + webhook writes `redeemed_at` (`RedeemOffer`). An offer checkout does not set + `trypost_first_month_coupon_id`, so it is not counted as the `$1` first month. + Offers apply only to the Welcome checkout (Socials monthly). +- **Copy.** Name, price and period live in `lang//offers.php` under the + slug with dashes as underscores (`black-friday-2026` → `black_friday_2026`: + `name`, `price`, `period`), in all 16 locales; the price is display text per + locale, like `billing.subscribe.prices.first_month`. Nothing about the discount is + stored in the database. The Welcome plan step shows the badge + (`welcome.plan_offer_badge`) and the offer price; without the name it shows + `welcome.plan_offer_badge_generic`, and without the price it keeps the normal + price, never a raw key. `tests/Feature/Offer/OfferCopyTest.php` fails when an + offer row has no copy. +- **Adding an offer.** Create the coupon in Stripe (no dates, no limit), add a + data migration that inserts the `offers` row (`DB::table`, `Str::uuid7()`), add + its copy to all 16 locales, deploy. + ## Multiple social accounts per network A workspace may connect as many accounts of the same network as it wants (two diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index 95c484288c..cd490f4c4c 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -26,9 +26,12 @@ public function redirect(Account $account, string $priceId, string $cancelUrl, ? $monthlyPlan = $this->monthlyPlan($plan, $priceId); $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId); + $offer = ConfigureSubscriptionCheckout::offerFor($account, $monthlyPlan); + ConfigureSubscriptionCheckout::apply($subscription, $account, $monthlyPlan); $subscription->withMetadata(array_filter([ - ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY => $subscription->couponId, + ConfigureSubscriptionCheckout::FIRST_MONTH_COUPON_METADATA_KEY => blank($offer) ? $subscription->couponId : null, + ConfigureSubscriptionCheckout::OFFER_METADATA_KEY => $offer?->id, ], filled(...))); $session = $subscription->checkout([ @@ -37,7 +40,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::FIRST_MONTH_OFFER_METADATA_KEY => blank($offer) && filled($subscription->couponId) ? 'true' : 'false', ], filled(...)), ]); diff --git a/app/Actions/Offer/ClaimOffer.php b/app/Actions/Offer/ClaimOffer.php new file mode 100644 index 0000000000..35cf46c19a --- /dev/null +++ b/app/Actions/Offer/ClaimOffer.php @@ -0,0 +1,36 @@ +where('slug', $slug)->first(); + + if (blank($offer) || $offer->slug !== $slug || $account->hasActiveSubscription()) { + return null; + } + + if ($account->offers()->whereKey($offer->id)->wherePivotNotNull('redeemed_at')->exists()) { + return null; + } + + $account->offers()->newPivotStatement()->upsert( + [['account_id' => $account->id, 'offer_id' => $offer->id, 'claimed_at' => now()]], + ['account_id', 'offer_id'], + ['claimed_at'], + ); + + return $offer; + } +} diff --git a/app/Actions/Offer/RedeemOffer.php b/app/Actions/Offer/RedeemOffer.php new file mode 100644 index 0000000000..7ee6db9ea3 --- /dev/null +++ b/app/Actions/Offer/RedeemOffer.php @@ -0,0 +1,26 @@ +offers() + ->wherePivotNull('redeemed_at') + ->updateExistingPivot($offerId, ['redeemed_at' => now()]); + } +} diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index aa06d57a3d..7f9ff39b05 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -18,11 +18,13 @@ use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; use App\Http\Requests\App\Welcome\StoreWelcomeSoftwareRequest; use App\Http\Resources\App\PlanResource; +use App\Models\Offer; use App\Models\Plan; use App\Services\PostHogService; use App\Support\Billing\ConfigureSubscriptionCheckout; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Lang; use Inertia\Inertia; use Inertia\Response as InertiaResponse; use Symfony\Component\HttpFoundation\Response; @@ -158,9 +160,31 @@ public function plan(Request $request): InertiaResponse|RedirectResponse return Inertia::render('welcome/Plan', [ 'plan' => PlanResource::make($plan)->resolve(), 'firstMonthOffer' => ConfigureSubscriptionCheckout::offersFirstMonthCoupon($request->user()->account, $plan), + 'offer' => $this->offerProps(ConfigureSubscriptionCheckout::offerFor($request->user()->account, $plan)), ]); } + /** + * The offer's copy lives in lang//offers.php; the page shows its + * name and price only when the keys exist, never the raw key. + * + * @return array{key: string, has_name: bool, has_price: bool}|null + */ + private function offerProps(?Offer $offer): ?array + { + if (blank($offer)) { + return null; + } + + $key = $offer->langKey(); + + return [ + 'key' => $key, + 'has_name' => Lang::has("offers.{$key}.name"), + 'has_price' => Lang::has("offers.{$key}.price") && Lang::has("offers.{$key}.period"), + ]; + } + public function storePlan( Request $request, StartSubscriptionCheckout $checkout, @@ -177,6 +201,7 @@ public function storePlan( abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Price is not configured.'); $firstMonthOffer = ConfigureSubscriptionCheckout::offersFirstMonthCoupon($user->account, $plan); + $offer = ConfigureSubscriptionCheckout::offerFor($user->account, $plan); $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan); try { @@ -187,6 +212,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..c48736a472 --- /dev/null +++ b/app/Http/Middleware/App/CaptureOffer.php @@ -0,0 +1,44 @@ +query('offer'); + + if (is_string($slug) && filled($slug) && Offer::available()->where('slug', $slug)->exists()) { + $request->session()->put(self::SESSION_KEY, $slug); + } + + $user = $request->user(); + + if (filled($user) && $request->session()->has(self::SESSION_KEY)) { + $pendingSlug = $request->session()->pull(self::SESSION_KEY); + + if ($user->isAccountOwner() && filled($user->account)) { + ClaimOffer::execute($user->account, $pendingSlug); + } + } + + return $next($request); + } +} diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index fa3f754e92..9ec7c76724 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; @@ -70,6 +72,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload): { $previousPlan = $account->plan?->name; + RedeemOffer::execute($account, data_get($payload, 'data.object.metadata.'.ConfigureSubscriptionCheckout::OFFER_METADATA_KEY)); $this->syncPlanFromSubscription($account, $payload, clearGenericTrial: true); $this->recordFirstPayment($account, $payload); diff --git a/app/Models/Account.php b/app/Models/Account.php index 0a683e7f39..0525e639e3 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,24 @@ 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 that + * is still available and this account has not redeemed. + */ + public function currentOffer(): ?Offer + { + return $this->offers() + ->available() + ->wherePivotNull('redeemed_at') + ->orderByPivot('claimed_at', 'desc') + ->first(); + } + public function posts(): HasManyThrough { return $this->hasManyThrough(Post::class, Workspace::class); diff --git a/app/Models/Offer.php b/app/Models/Offer.php new file mode 100644 index 0000000000..e2b483b70c --- /dev/null +++ b/app/Models/Offer.php @@ -0,0 +1,72 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'slug', + 'stripe_coupon_id', + 'starts_at', + 'ends_at', + 'max_redemptions', + ]; + + protected function casts(): array + { + return [ + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'max_redemptions' => 'integer', + ]; + } + + public function accounts(): BelongsToMany + { + return $this->belongsToMany(Account::class)->withPivot('claimed_at', '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 + ->where('offers.starts_at', '<=', now()) + ->where(function (Builder $query): void { + $query->whereNull('offers.ends_at')->orWhere('offers.ends_at', '>', now()); + }) + ->where(function (Builder $query): void { + $query->whereNull('offers.max_redemptions') + ->orWhere('offers.max_redemptions', '>', function (QueryBuilder $query): void { + $query->selectRaw('count(*)') + ->from('account_offer') + ->whereColumn('account_offer.offer_id', 'offers.id') + ->whereNotNull('account_offer.redeemed_at'); + }); + }); + } + + /** + * 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..218d95dfa7 100644 --- a/app/Support/Billing/ConfigureSubscriptionCheckout.php +++ b/app/Support/Billing/ConfigureSubscriptionCheckout.php @@ -5,6 +5,7 @@ namespace App\Support\Billing; use App\Models\Account; +use App\Models\Offer; use App\Models\Plan; use Laravel\Cashier\SubscriptionBuilder; use RuntimeException; @@ -19,6 +20,8 @@ final class ConfigureSubscriptionCheckout public const FIRST_MONTH_OFFER_METADATA_KEY = 'trypost_first_month_offer'; + public const OFFER_METADATA_KEY = 'trypost_offer_id'; + /** * Stripe Checkout rejects subscription trials shorter than 48 hours. */ @@ -29,6 +32,19 @@ final class ConfigureSubscriptionCheckout */ public static function apply(SubscriptionBuilder $subscription, Account $account, ?Plan $plan = null): SubscriptionBuilder { + $offer = self::offerFor($account, $plan); + + if (filled($offer)) { + if ((bool) config('cashier.allow_promotion_codes', false)) { + throw new RuntimeException( + 'Cannot apply an offer coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled: ' + .'Stripe Checkout rejects discounts and allow_promotion_codes on the same session.' + ); + } + + return $subscription->withCoupon($offer->stripe_coupon_id); + } + $couponId = self::firstMonthCouponId($account, $plan); if ($couponId !== null) { @@ -62,7 +78,16 @@ public static function apply(SubscriptionBuilder $subscription, Account $account public static function offersFirstMonthCoupon(Account $account, Plan $plan): bool { - return self::firstMonthCouponId($account, $plan) !== null; + return blank(self::offerFor($account, $plan)) && self::firstMonthCouponId($account, $plan) !== null; + } + + /** + * 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 filled($plan) ? $account->currentOffer() : null; } private static function firstMonthCouponId(Account $account, ?Plan $plan): ?string 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/database/factories/OfferFactory.php b/database/factories/OfferFactory.php new file mode 100644 index 0000000000..635e6f03bc --- /dev/null +++ b/database/factories/OfferFactory.php @@ -0,0 +1,52 @@ + + */ +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, + ]; + } + + 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, + ]); + } +} 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..c91f1c8120 --- /dev/null +++ b/database/migrations/2026_10_26_232641_create_offers_table.php @@ -0,0 +1,28 @@ +uuid('id')->primary(); + $table->string('slug')->unique(); + $table->string('stripe_coupon_id'); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->unsignedInteger('max_redemptions')->nullable(); + $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/lang/ar/offers.php b/lang/ar/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/ar/offers.php @@ -0,0 +1,4 @@ + 'اربط حساباتك وجدول أول منشور لك اليوم.', 'plan_first_month' => 'للشهر الأول', 'plan_terms' => 'ثم :price/شهر. ألغِ في أي وقت.', + 'plan_offer_badge' => 'يظهر لك هذا السعر بفضل عرض :offer.', + 'plan_offer_badge_generic' => 'يظهر لك سعر خاص.', 'plan_per_month' => 'شهريًا', 'plan_cancel_anytime' => 'ألغِ في أي وقت.', 'mcp' => [ diff --git a/lang/de/offers.php b/lang/de/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/de/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => 'Du siehst diesen Preis dank :offer.', + 'plan_offer_badge_generic' => 'Du siehst einen Sonderpreis.', 'plan_per_month' => 'pro Monat', 'plan_cancel_anytime' => 'Jederzeit kündbar.', 'mcp' => [ diff --git a/lang/el/offers.php b/lang/el/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/el/offers.php @@ -0,0 +1,4 @@ + 'Συνδέστε τα κανάλια σας και προγραμματίστε την πρώτη σας ανάρτηση σήμερα.', 'plan_first_month' => 'για τον πρώτο μήνα', 'plan_terms' => 'Μετά :price/μήνα. Ακύρωση οποτεδήποτε.', + 'plan_offer_badge' => 'Βλέπετε αυτή την τιμή χάρη στην προσφορά :offer.', + 'plan_offer_badge_generic' => 'Βλέπετε μια ειδική τιμή.', 'plan_per_month' => 'τον μήνα', 'plan_cancel_anytime' => 'Ακύρωση οποτεδήποτε.', 'mcp' => [ diff --git a/lang/en/offers.php b/lang/en/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/en/offers.php @@ -0,0 +1,4 @@ + 'Connect your channels and schedule your first post today.', 'plan_first_month' => 'for your first month', 'plan_terms' => 'Then :price/month. Cancel anytime.', + 'plan_offer_badge' => 'You\'re seeing this price thanks to :offer.', + 'plan_offer_badge_generic' => 'You\'re seeing a special price.', 'plan_per_month' => 'per month', 'plan_cancel_anytime' => 'Cancel anytime.', 'mcp' => [ diff --git a/lang/es/offers.php b/lang/es/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/es/offers.php @@ -0,0 +1,4 @@ + 'Conecta tus redes y programa tu primer post hoy.', 'plan_first_month' => 'el primer mes', 'plan_terms' => 'Luego, :price/mes. Cancela cuando quieras.', + 'plan_offer_badge' => 'Estás viendo este precio gracias a :offer.', + 'plan_offer_badge_generic' => 'Estás viendo un precio especial.', 'plan_per_month' => 'al mes', 'plan_cancel_anytime' => 'Cancela cuando quieras.', 'mcp' => [ diff --git a/lang/fr/offers.php b/lang/fr/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/fr/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => 'Vous voyez ce prix grâce à :offer.', + 'plan_offer_badge_generic' => 'Vous voyez un prix spécial.', 'plan_per_month' => 'par mois', 'plan_cancel_anytime' => 'Annulez à tout moment.', 'mcp' => [ diff --git a/lang/it/offers.php b/lang/it/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/it/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => 'Vedi questo prezzo grazie a :offer.', + 'plan_offer_badge_generic' => 'Vedi un prezzo speciale.', 'plan_per_month' => 'al mese', 'plan_cancel_anytime' => 'Disdici quando vuoi.', 'mcp' => [ diff --git a/lang/ja/offers.php b/lang/ja/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/ja/offers.php @@ -0,0 +1,4 @@ + 'チャンネルを連携して、今日最初の投稿を予約しましょう。', 'plan_first_month' => '初月の料金', 'plan_terms' => 'その後は:price/月。いつでも解約できます。', + 'plan_offer_badge' => ':offerの特別価格が適用されています。', + 'plan_offer_badge_generic' => '特別価格が適用されています。', 'plan_per_month' => '毎月', 'plan_cancel_anytime' => 'いつでも解約できます。', 'mcp' => [ diff --git a/lang/ko/offers.php b/lang/ko/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/ko/offers.php @@ -0,0 +1,4 @@ + '채널을 연결하고 오늘 첫 게시물을 예약하세요.', 'plan_first_month' => '첫 달 요금', 'plan_terms' => '이후 :price/월. 언제든지 해지 가능.', + 'plan_offer_badge' => ':offer 덕분에 이 가격으로 이용할 수 있어요.', + 'plan_offer_badge_generic' => '특별 가격으로 이용할 수 있어요.', 'plan_per_month' => '매월', 'plan_cancel_anytime' => '언제든지 해지 가능.', 'mcp' => [ diff --git a/lang/nl/offers.php b/lang/nl/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/nl/offers.php @@ -0,0 +1,4 @@ + 'Koppel je kanalen en plan vandaag je eerste post.', 'plan_first_month' => 'voor de eerste maand', 'plan_terms' => 'Daarna :price/maand. Altijd opzegbaar.', + 'plan_offer_badge' => 'Je ziet deze prijs dankzij :offer.', + 'plan_offer_badge_generic' => 'Je ziet een speciale prijs.', 'plan_per_month' => 'per maand', 'plan_cancel_anytime' => 'Altijd opzegbaar.', 'mcp' => [ diff --git a/lang/pl/offers.php b/lang/pl/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/pl/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => 'Ta cena obowiązuje dzięki promocji :offer.', + 'plan_offer_badge_generic' => 'To jest cena promocyjna.', 'plan_per_month' => 'miesięcznie', 'plan_cancel_anytime' => 'Anuluj w dowolnym momencie.', 'mcp' => [ diff --git a/lang/pt-BR/offers.php b/lang/pt-BR/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/pt-BR/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => 'Você está vendo este preço pela promoção :offer.', + 'plan_offer_badge_generic' => 'Você está vendo um preço especial.', 'plan_per_month' => 'por mês', 'plan_cancel_anytime' => 'Cancele quando quiser.', 'mcp' => [ diff --git a/lang/ru/offers.php b/lang/ru/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/ru/offers.php @@ -0,0 +1,4 @@ + 'Подключите каналы и запланируйте первый пост уже сегодня.', 'plan_first_month' => 'за первый месяц', 'plan_terms' => 'Затем :price/мес. Отмена в любой момент.', + 'plan_offer_badge' => 'Эта цена действует благодаря акции :offer.', + 'plan_offer_badge_generic' => 'Это специальная цена.', 'plan_per_month' => 'в месяц', 'plan_cancel_anytime' => 'Отмена в любой момент.', 'mcp' => [ diff --git a/lang/tr/offers.php b/lang/tr/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/tr/offers.php @@ -0,0 +1,4 @@ + '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_offer_badge' => ':offer sayesinde bu fiyatı görüyorsunuz.', + 'plan_offer_badge_generic' => 'Özel bir fiyat görüyorsunuz.', 'plan_per_month' => 'aylık', 'plan_cancel_anytime' => 'İstediğin zaman iptal et.', 'mcp' => [ diff --git a/lang/uk/offers.php b/lang/uk/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/uk/offers.php @@ -0,0 +1,4 @@ + 'Підключіть канали та заплануйте перший пост уже сьогодні.', 'plan_first_month' => 'за перший місяць', 'plan_terms' => 'Потім :price/міс. Скасування будь-коли.', + 'plan_offer_badge' => 'Ця ціна діє завдяки акції :offer.', + 'plan_offer_badge_generic' => 'Це спеціальна ціна.', 'plan_per_month' => 'на місяць', 'plan_cancel_anytime' => 'Скасування будь-коли.', 'mcp' => [ diff --git a/lang/zh/offers.php b/lang/zh/offers.php new file mode 100644 index 0000000000..b62512838d --- /dev/null +++ b/lang/zh/offers.php @@ -0,0 +1,4 @@ + '连接你的账号,今天就排期第一条帖子。', 'plan_first_month' => '首月价格', 'plan_terms' => '之后 :price/月,随时取消。', + 'plan_offer_badge' => '你看到的是 :offer 的优惠价格。', + 'plan_offer_badge_generic' => '你看到的是优惠价格。', 'plan_per_month' => '每月', 'plan_cancel_anytime' => '随时取消。', 'mcp' => [ diff --git a/resources/js/pages/welcome/Plan.vue b/resources/js/pages/welcome/Plan.vue index 85645848fe..eb510cfe08 100644 --- a/resources/js/pages/welcome/Plan.vue +++ b/resources/js/pages/welcome/Plan.vue @@ -1,10 +1,11 @@