diff --git a/.cursor/skills/copywriting/SKILL.md b/.cursor/skills/copywriting/SKILL.md new file mode 100644 index 000000000..ac03fdcc4 --- /dev/null +++ b/.cursor/skills/copywriting/SKILL.md @@ -0,0 +1,256 @@ +--- +name: copywriting +description: When the user wants to write, rewrite, or improve marketing copy for any page — including homepage, landing pages, pricing pages, feature pages, about pages, or product pages. Also use when the user says "write copy for," "improve this copy," "rewrite this page," "marketing copy," "headline help," "CTA copy," "value proposition," "tagline," "subheadline," "hero section copy," "above the fold," "this copy is weak," "make this more compelling," or "help me describe my product." Use this whenever someone is working on website text that needs to persuade or convert. For email copy, see emails. For popup copy, see popups. For editing existing copy, see copy-editing. For the offer underneath the copy (bonuses, guarantees, value framing), see offers. +metadata: + version: 2.0.2 +--- + +# Copywriting + +You are an expert conversion copywriter. Your goal is to write marketing copy that is clear, compelling, and drives action. + +## Before Writing + +**Check for product marketing context first:** +If `.agents/product-marketing.md` exists (or `.claude/product-marketing.md`, or the legacy `product-marketing-context.md` filename, in older setups), read it before asking questions. Use that context and only ask for information not already covered or specific to this task. + +Gather this context (ask if not provided): + +### 1. Page Purpose +- What type of page? (homepage, landing page, pricing, feature, about) +- What is the ONE primary action you want visitors to take? + +### 2. Audience +- Who is the ideal customer? +- What problem are they trying to solve? +- What objections or hesitations do they have? +- What language do they use to describe their problem? + +### 3. Product/Offer +- What are you selling or offering? +- What makes it different from alternatives? +- What's the key transformation or outcome? +- Any proof points (numbers, testimonials, case studies)? + +### 4. Context +- Where is traffic coming from? (ads, organic, email) +- What do visitors already know before arriving? + +--- + +## Copywriting Principles + +### Clarity Over Cleverness +If you have to choose between clear and creative, choose clear. Clarity is not just tidier — it converts: clearer positioning and copy is associated with +81% conversions, a 38% shorter sales cycle, 28% lower CAC, and 175% more referrals. When a reader has to decode your line, you've lost them. + +**For message-market fit tools** — the "Now you can" test, the Human Action Model (discomfort → vision → path), the Perception Gap, and the clarity metrics: See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit) + +### Benefits Over Features +Features: What it does. Benefits: What that means for the customer. + +### Specificity Over Vagueness +- Vague: "Save time on your workflow" +- Specific: "Cut your weekly reporting from 4 hours to 15 minutes" + +### Customer Language Over Company Language +Use words your customers use. Mirror voice-of-customer from reviews, interviews, support tickets. + +### One Idea Per Section +Each section should advance one argument. Build a logical flow down the page. + +--- + +## Writing Style Rules + +### Core Principles + +1. **Simple over complex** — "Use" not "utilize," "help" not "facilitate" +2. **Specific over vague** — Avoid "streamline," "optimize," "innovative" +3. **Active over passive** — "We generate reports" not "Reports are generated" +4. **Confident over qualified** — Remove "almost," "very," "really" +5. **Show over tell** — Describe the outcome instead of using adverbs +6. **Honest over sensational** — Fabricated statistics or testimonials erode trust and create legal liability + +### Quick Quality Check + +- Jargon that could confuse outsiders? +- Sentences trying to do too much? +- Passive voice constructions? +- Exclamation points? (remove them) +- Marketing buzzwords without substance? + +For thorough line-by-line review, use the **copy-editing** skill after your draft. + +--- + +## Best Practices + +### Be Direct +Get to the point. Don't bury the value in qualifications. + +❌ Slack lets you share files instantly, from documents to images, directly in your conversations + +✅ Need to share a screenshot? Send as many documents, images, and audio files as your heart desires. + +### Use Rhetorical Questions +Questions engage readers and make them think about their own situation. +- "Hate returning stuff to Amazon?" +- "Tired of chasing approvals?" + +### Use Analogies When Helpful +Analogies make abstract concepts concrete and memorable. + +### Pepper in Humor (When Appropriate) +Puns and wit make copy memorable—but only if it fits the brand and doesn't undermine clarity. + +--- + +## Page Structure Framework + +### Above the Fold + +**Headline** +- Your single most important message +- Communicate core value proposition +- Specific > generic + +**Example formulas:** +- "{Achieve outcome} without {pain point}" +- "The {category} for {audience}" +- "Never {unpleasant event} again" +- "{Question highlighting main pain point}" + +**For comprehensive headline formulas**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +**Structure the hero as a transformation** — current discomfort → better vision → path to action (the Human Action Model), then run every headline through the "Now you can" test. See [references/copy-frameworks.md](references/copy-frameworks.md#clarity--message-market-fit) + +**For natural transition phrases**: See [references/natural-transitions.md](references/natural-transitions.md) + +**Subheadline** +- Expands on headline +- Adds specificity +- 1-2 sentences max + +**Primary CTA** +- Action-oriented button text +- Communicate what they get: "Start Free Trial" > "Sign Up" + +### Core Sections + +| Section | Purpose | +|---------|---------| +| Social Proof | Build credibility (logos, stats, testimonials) | +| Problem/Pain | Show you understand their situation | +| Solution/Benefits | Connect to outcomes (3-5 key benefits) | +| How It Works | Reduce perceived complexity (3-4 steps) | +| Objection Handling | FAQ, comparisons, guarantees | +| Final CTA | Recap value, repeat CTA, risk reversal | + +**For detailed section types and page templates**: See [references/copy-frameworks.md](references/copy-frameworks.md) + +--- + +## CTA Copy Guidelines + +**Weak CTAs (avoid):** +- Submit, Sign Up, Learn More, Click Here, Get Started + +**Strong CTAs (use):** +- Start Free Trial +- Get [Specific Thing] +- See [Product] in Action +- Create Your First [Thing] +- Download the Guide + +**Formula:** [Action Verb] + [What They Get] + [Qualifier if needed] + +Examples: +- "Start My Free Trial" +- "Get the Complete Checklist" +- "See Pricing for My Team" + +--- + +## Page-Specific Guidance + +### Homepage +- Serve multiple audiences without being generic +- Lead with broadest value proposition +- Provide clear paths for different visitor intents + +### Landing Page +- Single message, single CTA +- Match headline to ad/traffic source +- Complete argument on one page + +### Pricing Page +- Help visitors choose the right plan +- Address "which is right for me?" anxiety +- Make recommended plan obvious + +### Feature Page +- Connect feature → benefit → outcome +- Show use cases and examples +- Clear path to try or buy + +### About Page +- Tell the story of why you exist +- Connect mission to customer benefit +- Still include a CTA + +--- + +## Voice and Tone + +Before writing, establish: + +**Formality level:** +- Casual/conversational +- Professional but friendly +- Formal/enterprise + +**Brand personality:** +- Playful or serious? +- Bold or understated? +- Technical or accessible? + +Maintain consistency, but adjust intensity: +- Headlines can be bolder +- Body copy should be clearer +- CTAs should be action-oriented + +--- + +## Output Format + +When writing copy, provide: + +### Page Copy +Organized by section: +- Headline, Subheadline, CTA +- Section headers and body copy +- Secondary CTAs + +### Annotations +For key elements, explain: +- Why you made this choice +- What principle it applies + +### Alternatives +For headlines and CTAs, provide 2-3 options: +- Option A: [copy] — [rationale] +- Option B: [copy] — [rationale] + +### Meta Content (if relevant) +- Page title (for SEO) +- Meta description + +--- + +## Related Skills + +- **copy-editing**: For polishing existing copy (use after your draft) +- **cro**: If page structure/strategy needs work, not just copy +- **emails**: For email copywriting +- **popups**: For popup and modal copy +- **ab-testing**: To test copy variations \ No newline at end of file diff --git a/.cursor/skills/copywriting/evals/evals.json b/.cursor/skills/copywriting/evals/evals.json new file mode 100644 index 000000000..95a862825 --- /dev/null +++ b/.cursor/skills/copywriting/evals/evals.json @@ -0,0 +1,126 @@ +{ + "skill_name": "copywriting", + "evals": [ + { + "id": 1, + "prompt": "Write homepage copy for a SaaS tool that automates employee onboarding. Target audience is HR directors at mid-size companies (200-2000 employees). Main differentiator is that it integrates with all major HRIS systems and cuts onboarding time from 2 weeks to 2 days.", + "expected_output": "Should check for product-marketing.md first. Should write full page copy organized by section: Headline, Subheadline, CTA (above the fold), then Social Proof, Problem/Pain, Solution/Benefits, How It Works, Objection Handling, and Final CTA. Should follow copywriting principles: clarity over cleverness, benefits over features, specificity (use the '2 weeks to 2 days' stat), customer language. Headline should communicate core value proposition. CTAs should be action-oriented ('Start Free Trial' not 'Submit'). Should provide 2-3 headline alternatives with rationale. Should include annotations explaining key copy choices. Should include meta content (SEO page title and meta description).", + "assertions": [ + "Checks for product-marketing.md", + "Writes full page copy organized by section", + "Includes Headline, Subheadline, and CTA above the fold", + "Includes Social Proof, Problem/Pain, Solution/Benefits, How It Works sections", + "Uses the '2 weeks to 2 days' specificity in copy", + "CTAs are action-oriented, not generic", + "Provides 2-3 headline alternatives with rationale", + "Includes annotations explaining copy choices", + "Includes meta content (SEO title and meta description)" + ], + "files": [] + }, + { + "id": 2, + "prompt": "Rewrite this headline: 'An Innovative AI-Powered Platform for Streamlined Business Operations' — it's for a B2B SaaS tool that helps small businesses manage invoicing and payments.", + "expected_output": "Should identify problems: jargon ('innovative,' 'AI-powered,' 'streamlined,' 'business operations'), too vague, company language not customer language. Should apply copywriting principles — specificity over vagueness, benefits over features, customer language over company language. Should provide 2-3 alternative headlines using formulas like '{Achieve outcome} without {pain point}' or 'The {category} for {audience}'. Each alternative should include rationale. Should also suggest a subheadline that adds specificity.", + "assertions": [ + "Identifies jargon in original headline", + "Identifies vagueness as a problem", + "Identifies company language vs customer language issue", + "Provides 2-3 alternative headlines", + "Alternatives use headline formulas from the skill", + "Each alternative includes rationale", + "Suggests a subheadline" + ], + "files": [] + }, + { + "id": 3, + "prompt": "i need copy for my pricing page. we have three plans: starter ($29/mo), pro ($79/mo), business ($199/mo). it's a social media scheduling tool for marketers", + "expected_output": "Should trigger on the casual phrasing. Should ask or infer audience context. Should apply Pricing Page guidance: help visitors choose the right plan, address 'which is right for me?' anxiety, make recommended plan obvious. Should write plan names, descriptions, feature lists with benefit-oriented copy (not just feature names). Should include a page headline that addresses the pricing decision. CTAs should be specific per plan. Should handle objection handling (FAQ copy). Should provide alternatives for key elements.", + "assertions": [ + "Triggers on casual phrasing", + "Applies Pricing Page guidance", + "Addresses 'which plan is right for me' anxiety", + "Makes recommended plan obvious", + "Writes benefit-oriented feature copy, not just feature names", + "Includes page headline", + "CTAs are specific per plan", + "Includes FAQ or objection handling copy", + "Provides alternatives for key elements" + ], + "files": [] + }, + { + "id": 4, + "prompt": "Write copy for our About page. We're a 3-person startup that built a developer tool for database migrations. Founded because we kept losing data during migrations at our last jobs. Tone should be professional but human.", + "expected_output": "Should apply About Page guidance: tell the story of why you exist, connect mission to customer benefit, still include a CTA. Should adapt voice and tone to 'professional but human' as specified. Should tell the founder origin story authentically. Should connect the personal pain to the customer's pain. Should include a CTA even on the About page. Copy should follow style rules: active voice, confident, specific. Should NOT be overly corporate or generic.", + "assertions": [ + "Applies About Page guidance", + "Tells the story of why the company exists", + "Connects mission to customer benefit", + "Includes a CTA", + "Adapts tone to professional but human", + "Uses the founder origin story", + "Connects personal pain to customer pain", + "Uses active voice", + "Avoids corporate jargon" + ], + "files": [] + }, + { + "id": 5, + "prompt": "Can you improve this CTA? We currently have 'Learn More' on our feature page for our analytics dashboard product.", + "expected_output": "Should immediately identify 'Learn More' as a weak CTA per the guidelines. Should apply the CTA formula: [Action Verb] + [What They Get] + [Qualifier]. Should provide 2-3 strong alternatives like 'See the Dashboard in Action,' 'Start Your Free Trial,' or 'Explore Analytics Features.' Each alternative should include rationale and context for when it works best. Should also consider CTA hierarchy — whether this is a primary or secondary CTA, and suggest complementary CTAs if relevant.", + "assertions": [ + "Identifies 'Learn More' as a weak CTA", + "Applies the CTA formula from the skill", + "Provides 2-3 strong alternatives", + "Each alternative includes rationale", + "Considers CTA hierarchy (primary vs secondary)", + "Suggests complementary CTAs" + ], + "files": [] + }, + { + "id": 6, + "prompt": "Write me a 5-email welcome sequence for new trial users of our project management tool.", + "expected_output": "Should recognize this is an email copywriting task, not page copywriting. Should defer to or cross-reference the emails skill, which specifically handles email sequences, drip campaigns, and lifecycle emails. May provide brief general guidance but should make clear that emails is the right skill for this task.", + "assertions": [ + "Recognizes this as email sequence work", + "References or defers to emails skill", + "Does not attempt to write a full email sequence using page copywriting patterns" + ], + "files": [] + }, + { + "id": 7, + "prompt": "Review this copy and tell me what's wrong: 'We are extremely excited to announce our revolutionary, cutting-edge platform that will totally transform how businesses optimize their workflows! Sign up now!!'", + "expected_output": "Should apply the Quick Quality Check. Should identify: exclamation points (remove them), marketing buzzwords without substance ('revolutionary,' 'cutting-edge,' 'totally transform,' 'optimize'), passive/weak constructions ('we are excited to announce'), vague language ('workflows'). Should apply writing style rules: simple over complex, specific over vague, confident over qualified, show over tell. Should rewrite the copy following these principles. Should provide 2-3 alternatives.", + "assertions": [ + "Identifies exclamation point overuse", + "Identifies marketing buzzwords without substance", + "Identifies vague language", + "Applies writing style rules", + "Rewrites the copy following principles", + "Provides alternatives", + "Result is specific, clear, and jargon-free" + ], + "files": [] + }, + { + "id": 8, + "prompt": "Write above-the-fold copy for a calendar scheduling tool. Our differentiator is that the recipient gets to overlay their own calendar on the invite, so picking a time feels fair to both people instead of one-sided. Same product needs to work for indie founders AND for enterprise ops teams.", + "expected_output": "Should structure the hero using the Human Action Model transformation spine: current discomfort (the awkwardness of sending a one-sided scheduling link), better vision (scheduling that feels considerate to both people), and path to action (the overlay mechanic + a specific CTA). Should run headline candidates through the 'Now you can' test and prefer lines that are compelling and true. Should reference or echo the SavvyCal awkward-link insight ('You shouldn't have to feel awkward sending out your scheduling link') as the message-market-fit model. Should surface the Perception Gap: the same benefit reads differently by risk tolerance, so it should provide a value-prop swap — a founder-facing framing (speed, no sales calls) and an enterprise-facing framing (security, SLAs, reliability) rather than one averaged, mushy message. Should favor clarity over cleverness and provide 2-3 headline alternatives with rationale.", + "assertions": [ + "Structures the hero as discomfort -> vision -> path (Human Action Model)", + "Applies the 'Now you can' test to headline candidates", + "References the SavvyCal awkward-link message-market-fit insight", + "Surfaces the Perception Gap between segments", + "Provides a value-prop swap: founder framing vs enterprise framing", + "Favors clarity over cleverness", + "Provides 2-3 headline alternatives with rationale" + ], + "files": [] + } + ] + } \ No newline at end of file diff --git a/.cursor/skills/copywriting/references/copy-frameworks.md b/.cursor/skills/copywriting/references/copy-frameworks.md new file mode 100644 index 000000000..9d2f2d0c0 --- /dev/null +++ b/.cursor/skills/copywriting/references/copy-frameworks.md @@ -0,0 +1,433 @@ +# Copy Frameworks Reference + +Headline formulas, page section types, and structural templates. + +## Contents +- Headline Formulas (outcome-focused, problem-focused, audience-focused, differentiation-focused, proof-focused, additional formulas) +- Landing Page Section Types (core sections, supporting sections) +- Page Structure Templates (feature-heavy page, varied engaging page, compact landing page, enterprise/B2B landing page, product launch page) +- Section Writing Tips (problem section, benefits section, how it works section, testimonial selection) +- Clarity & Message-Market Fit (the "Now you can" test, Human Action Model, the Perception Gap, the SavvyCal case, clarity metrics) + +## Headline Formulas + +### Outcome-Focused + +**{Achieve desirable outcome} without {pain point}** +> Understand how users are really experiencing your site without drowning in numbers + +**{Achieve desirable outcome} by {how product makes it possible}** +> Generate more leads by seeing which companies visit your site + +**Turn {input} into {outcome}** +> Turn your hard-earned sales into repeat customers + +**[Achieve outcome] in [timeframe]** +> Get your tax refund in 10 days + +--- + +### Problem-Focused + +**Never {unpleasant event} again** +> Never miss a sales opportunity again + +**{Question highlighting the main pain point}** +> Hate returning stuff to Amazon? + +**Stop [pain]. Start [pleasure].** +> Stop chasing invoices. Start getting paid on time. + +--- + +### Audience-Focused + +**{Key feature/product type} for {target audience}** +> Advanced analytics for Shopify e-commerce + +**{Key feature/product type} for {target audience} to {what it's used for}** +> An online whiteboard for teams to ideate and brainstorm together + +**You don't have to {skills or resources} to {achieve desirable outcome}** +> With Ahrefs, you don't have to be an SEO pro to rank higher and get more traffic + +--- + +### Differentiation-Focused + +**The {opposite of usual process} way to {achieve desirable outcome}** +> The easiest way to turn your passion into income + +**The [category] that [key differentiator]** +> The CRM that updates itself + +--- + +### Proof-Focused + +**[Number] [people] use [product] to [outcome]** +> 50,000 marketers use Drip to send better emails + +**{Key benefit of your product}** +> Sound clear in online meetings + +--- + +### Additional Formulas + +**The simple way to {outcome}** +> The simple way to track your time + +**Finally, {category} that {benefit}** +> Finally, accounting software that doesn't suck + +**{Outcome} without {common pain}** +> Build your website without writing code + +**Get {benefit} from your {thing}** +> Get more revenue from your existing traffic + +**{Action verb} your {thing} like {admirable example}** +> Market your SaaS like a Fortune 500 + +**What if you could {desirable outcome}?** +> What if you could close deals 30% faster? + +**Everything you need to {outcome}** +> Everything you need to launch your course + +**The {adjective} {category} built for {audience}** +> The lightweight CRM built for startups + +--- + +## Landing Page Section Types + +### Core Sections + +**Hero (Above the Fold)** +- Headline + subheadline +- Primary CTA +- Supporting visual (product screenshot, hero image) +- Optional: Social proof bar + +**Social Proof Bar** +- Customer logos (recognizable > many) +- Key metric ("10,000+ teams") +- Star rating with review count +- Short testimonial snippet + +**Problem/Pain Section** +- Articulate their problem better than they can +- Create recognition ("that's exactly my situation") +- Hint at cost of not solving it + +**Solution/Benefits Section** +- Bridge from problem to your solution +- 3-5 key benefits (not 10) +- Each: headline + explanation + proof if available + +**How It Works** +- 3-4 numbered steps +- Reduces perceived complexity +- Each step: action + outcome + +**Final CTA Section** +- Recap value proposition +- Repeat primary CTA +- Risk reversal (guarantee, free trial) + +--- + +### Supporting Sections + +**Testimonials** +- Full quotes with names, roles, companies +- Photos when possible +- Specific results over vague praise +- Formats: quote cards, video, tweet embeds + +**Case Studies** +- Problem → Solution → Results +- Specific metrics and outcomes +- Customer name and context +- Can be snippets with "Read more" links + +**Use Cases** +- Different ways product is used +- Helps visitors self-identify +- "For marketers who need X" format + +**Personas / "Built For" Sections** +- Explicitly call out target audience +- "Perfect for [role]" blocks +- Addresses "Is this for me?" question + +**FAQ Section** +- Address common objections +- Good for SEO +- Reduces support burden +- 5-10 most common questions + +**Comparison Section** +- vs. competitors (name them or don't) +- vs. status quo (spreadsheets, manual processes) +- Tables or side-by-side format + +**Integrations / Partners** +- Logos of tools you connect with +- "Works with your stack" messaging +- Builds credibility + +**Founder Story / Manifesto** +- Why you built this +- What you believe +- Emotional connection +- Differentiates from faceless competitors + +**Demo / Product Tour** +- Interactive demos +- Video walkthroughs +- GIF previews +- Shows product in action + +**Pricing Preview** +- Teaser even on non-pricing pages +- Starting price or "from $X/mo" +- Moves decision-makers forward + +**Guarantee / Risk Reversal** +- Money-back guarantee +- Free trial terms +- "Cancel anytime" +- Reduces friction + +**Stats Section** +- Key metrics that build credibility +- "10,000+ customers" +- "4.9/5 rating" +- "$2M saved for customers" + +--- + +## Page Structure Templates + +### Feature-Heavy Page (Weak) + +``` +1. Hero +2. Feature 1 +3. Feature 2 +4. Feature 3 +5. Feature 4 +6. CTA +``` + +This is a list, not a persuasive narrative. + +--- + +### Varied, Engaging Page (Strong) + +``` +1. Hero with clear value prop +2. Social proof bar (logos or stats) +3. Problem/pain section +4. How it works (3 steps) +5. Key benefits (2-3, not 10) +6. Testimonial +7. Use cases or personas +8. Comparison to alternatives +9. Case study snippet +10. FAQ +11. Final CTA with guarantee +``` + +This tells a story and addresses objections. + +--- + +### Compact Landing Page + +``` +1. Hero (headline, subhead, CTA, image) +2. Social proof bar +3. 3 key benefits with icons +4. Testimonial +5. How it works (3 steps) +6. Final CTA with guarantee +``` + +Good for ad landing pages where brevity matters. + +--- + +### Enterprise/B2B Landing Page + +``` +1. Hero (outcome-focused headline) +2. Logo bar (recognizable companies) +3. Problem section (business pain) +4. Solution overview +5. Use cases by role/department +6. Security/compliance section +7. Integration logos +8. Case study with metrics +9. ROI/value section +10. Contact/demo CTA +``` + +Addresses enterprise buyer concerns. + +--- + +### Product Launch Page + +``` +1. Hero with launch announcement +2. Video demo or walkthrough +3. Feature highlights (3-5) +4. Before/after comparison +5. Early testimonials +6. Launch pricing or early access offer +7. CTA with urgency +``` + +Good for ProductHunt, launches, or announcements. + +--- + +## Section Writing Tips + +### Problem Section + +Start with phrases like: +- "You know the feeling..." +- "If you're like most [role]..." +- "Every day, [audience] struggles with..." +- "We've all been there..." + +Then describe: +- The specific frustration +- The time/money wasted +- The impact on their work/life + +### Benefits Section + +For each benefit, include: +- **Headline**: The outcome they get +- **Body**: How it works (1-2 sentences) +- **Proof**: Number, testimonial, or example (optional) + +### How It Works Section + +Each step should be: +- **Numbered**: Creates sense of progress +- **Simple verb**: "Connect," "Set up," "Get" +- **Outcome-oriented**: What they get from this step + +Example: +1. Connect your tools (takes 2 minutes) +2. Set your preferences +3. Get automated reports every Monday + +### Testimonial Selection + +Best testimonials include: +- Specific results ("increased conversions by 32%") +- Before/after context ("We used to spend hours...") +- Role + company for credibility +- Something quotable and specific + +Avoid testimonials that just say: +- "Great product!" +- "Love it!" +- "Easy to use!" + +--- + +## Clarity & Message-Market Fit + +Headline formulas give you the shape of a line. These tools tell you whether the line is actually *working* — whether it's clear, whether it maps to how the reader already thinks, and whether it lands with the right person. Positioning is the prologue to your novel: it sets up everything that follows. Get it clear and the rest of the page writes itself. + +### The "Now you can" Test + +A fast gut-check for any headline or benefit line. Mentally prefix it with **"Now you can…"**. If the result is both **compelling** and **true**, the line is doing its job. If it reads as vague, obvious, or a stretch, rewrite it. + +The test works because "Now you can…" forces the copy into the reader's world — it has to name a concrete new ability they didn't have before. Feature-speak and buzzwords collapse under it. + +| Original line | "Now you can…" version | Verdict | +|---------------|------------------------|---------| +| "Powerful analytics platform" | Now you can… have a powerful analytics platform | Fails — not a new ability, just a description | +| "See which companies visit your site" | Now you can… see which companies visit your site | Works — compelling + true | +| "Streamline your workflow" | Now you can… streamline your workflow | Fails — vague, unfalsifiable | +| "Send unlimited docs, images, and audio in one place" | Now you can… send unlimited docs, images, and audio in one place | Works — concrete + true | + +Use it as a filter, not a formula: draft with the headline formulas above, then run each candidate through "Now you can…" and keep the survivors. + +### The Human Action Model (landing-page narrative spine) + +Ludwig von Mises' Human Action Model explains *why* anyone acts: a person acts only when three things line up. Every above-the-fold that converts follows the same three-beat spine: + +1. **Current discomfort** — the felt problem, named in the reader's own words. They have to recognize their situation ("that's exactly me"). +2. **Better vision** — a clearly imagined, more satisfying state. What life looks like once the discomfort is gone. +3. **Path to action** — the belief that *this specific step* closes the gap between the two. The product is the bridge, and the CTA is how they cross it. + +Miss any beat and the reader stalls. No discomfort = no reason to move. No vision = no destination. No path = no reason to believe *you're* the way there. + +**Mapping it onto the hero:** + +| Beat | Where it usually lives | Example | +|------|------------------------|---------| +| Current discomfort | Eyebrow, subhead, or problem-framed headline | "You shouldn't have to feel awkward sending out your scheduling link" | +| Better vision | Headline or subhead | "Scheduling that feels considerate, not one-sided" | +| Path to action | CTA + supporting proof | "Start scheduling free" | + +This is the transformation spine underneath the "6 essential sections" of a landing page — hero, social proof, problem, solution, how-it-works, and final CTA. The hero states the transformation; the rest of the page substantiates each beat. + +### The Perception Gap + +The same benefit can read as a **selling point to one segment and a red flag to another**. The gap is between what *you* think you're saying and what a given reader hears through their own risk tolerance. + +The fix isn't softer copy — it's **matching the value prop to the reader's risk tolerance**. Segment first, then swap the framing. + +| Benefit as written | Startup / early-adopter hears | Enterprise / risk-averse hears | +|--------------------|-------------------------------|--------------------------------| +| "Move fast — ship in a weekend" | Speed, momentum (✅) | Immature, unstable (🚩) | +| "Brand-new approach" | Innovative edge (✅) | Unproven, risky (🚩) | +| "Enterprise-grade security & SLAs" | Bloated, slow, expensive (🚩) | Safe, trustworthy (✅) | +| "Trusted by the Fortune 500" | Not built for me (🚩) | Proven, de-risked (✅) | + +**Value-prop swap in practice** — same product, two audiences: + +- *Startup landing page:* "Ship your first integration this afternoon. No sales calls, no procurement." +- *Enterprise landing page:* "SOC 2 Type II, 99.99% uptime SLA, and a named implementation lead. Roll out with confidence." + +When a page has to serve both, don't average them into mush — segment the traffic (separate pages, or a persona split) and let each read its own version of the truth. + +### Worked Example — SavvyCal (message-market fit) + +SavvyCal (a scheduling tool) originally led with feature-forward copy. They rewrote the hero around a single felt discomfort: + +> **"You shouldn't have to feel awkward sending out your scheduling link."** + +That one line **roughly tripled (3×) conversions**. It works because it hits all three beats of the Human Action Model at once: + +- **Discomfort:** the small social awkwardness of "here's my link, pick a time" — named exactly as users feel it. +- **Vision:** scheduling that feels considerate to *both* people. +- **Path:** SavvyCal's overlay-your-calendar mechanic is the bridge, so the CTA feels like the obvious next step. + +The lesson: message-market fit beats feature lists. The winning line wasn't cleverer — it named a real feeling the reader hadn't heard a scheduling tool acknowledge before. Run your own hero through "Now you can…" and the Human Action Model to find that line. + +### Clarity Beats Cleverness (the metrics) + +When teams measure it, clarity — not wit — is what moves the numbers. Clearer positioning and copy is associated with: + +- **+81% conversions** +- **−38% sales cycle** (shorter time to close) +- **−28% CAC** (lower customer acquisition cost) +- **+175% referrals** + +The mechanism: clear copy lets the *right* buyer self-qualify fast and the wrong one bounce early, so every downstream metric improves. Clever copy that requires decoding does the opposite — it adds a comprehension tax at the exact moment attention is scarcest. + +**Practical rule:** if a reader has to pause to figure out what you mean, you've already lost. When forced to choose between a clever line and a clear one, ship the clear one — then use the tests above ("Now you can…", the Human Action Model, the Perception Gap) to make the clear line compelling too. \ No newline at end of file diff --git a/.cursor/skills/copywriting/references/natural-transitions.md b/.cursor/skills/copywriting/references/natural-transitions.md new file mode 100644 index 000000000..2811575fa --- /dev/null +++ b/.cursor/skills/copywriting/references/natural-transitions.md @@ -0,0 +1,272 @@ +# Natural Transitions + +Transitional phrases to guide readers through your content. Good signposting improves readability, user engagement, and helps search engines understand content structure. + +Adapted from: University of Manchester Academic Phrasebank (2023), Plain English Campaign, web content best practices + +--- + +## Contents +- Previewing Content Structure +- Introducing a New Topic +- Referring Back +- Moving Between Sections +- Indicating Addition +- Indicating Contrast +- Indicating Similarity +- Indicating Cause and Effect +- Giving Examples +- Emphasising Key Points +- Providing Evidence (neutral attribution, expert quotes, supporting claims) +- Summarising Sections +- Concluding Content +- Question-Based Transitions +- List Introductions +- Hedging Language +- Best Practice Guidelines +- Transitions to Avoid (AI Tells) + +## Previewing Content Structure + +Use to orient readers and set expectations: + +- Here's what we'll cover... +- This guide walks you through... +- Below, you'll find... +- We'll start with X, then move to Y... +- First, let's look at... +- Let's break this down step by step. +- The sections below explain... + +--- + +## Introducing a New Topic + +- When it comes to X,... +- Regarding X,... +- Speaking of X,... +- Now let's talk about X. +- Another key factor is... +- X is worth exploring because... + +--- + +## Referring Back + +Use to connect ideas and reinforce key points: + +- As mentioned earlier,... +- As we covered above,... +- Remember when we discussed X? +- Building on that point,... +- Going back to X,... +- Earlier, we explained that... + +--- + +## Moving Between Sections + +- Now let's look at... +- Next up:... +- Moving on to... +- With that covered, let's turn to... +- Now that you understand X, here's Y. +- That brings us to... + +--- + +## Indicating Addition + +- Also,... +- Plus,... +- On top of that,... +- What's more,... +- Another benefit is... +- Beyond that,... +- In addition,... +- There's also... + +**Note:** Use "moreover" and "furthermore" sparingly. They can sound AI-generated when overused. + +--- + +## Indicating Contrast + +- However,... +- But,... +- That said,... +- On the flip side,... +- In contrast,... +- Unlike X, Y... +- While X is true, Y... +- Despite this,... + +--- + +## Indicating Similarity + +- Similarly,... +- Likewise,... +- In the same way,... +- Just like X, Y also... +- This mirrors... +- The same applies to... + +--- + +## Indicating Cause and Effect + +- So,... +- This means... +- As a result,... +- That's why... +- Because of this,... +- This leads to... +- The outcome?... +- Here's what happens:... + +--- + +## Giving Examples + +- For example,... +- For instance,... +- Here's an example:... +- Take X, for instance. +- Consider this:... +- A good example is... +- To illustrate,... +- Like when... +- Say you want to... + +--- + +## Emphasising Key Points + +- Here's the key takeaway:... +- The important thing is... +- What matters most is... +- Don't miss this:... +- Pay attention to... +- This is critical:... +- The bottom line?... + +--- + +## Providing Evidence + +Use when citing sources, data, or expert opinions: + +### Neutral attribution +- According to [Source],... +- [Source] reports that... +- Research shows that... +- Data from [Source] indicates... +- A study by [Source] found... + +### Expert quotes +- As [Expert] puts it,... +- [Expert] explains,... +- In the words of [Expert],... +- [Expert] notes that... + +### Supporting claims +- This is backed by... +- Evidence suggests... +- The numbers confirm... +- This aligns with findings from... + +--- + +## Summarising Sections + +- To recap,... +- Here's the short version:... +- In short,... +- The takeaway?... +- So what does this mean?... +- Let's pull this together:... +- Quick summary:... + +--- + +## Concluding Content + +- Wrapping up,... +- The bottom line is... +- Here's what to do next:... +- To sum up,... +- Final thoughts:... +- Ready to get started?... +- Now it's your turn. + +**Note:** Avoid "In conclusion" at the start of a paragraph. It's overused and signals AI writing. + +--- + +## Question-Based Transitions + +Useful for conversational tone and featured snippet optimization: + +- So what does this mean for you? +- But why does this matter? +- How do you actually do this? +- What's the catch? +- Sound complicated? It's not. +- Wondering where to start? +- Still not sure? Here's the breakdown. + +--- + +## List Introductions + +For numbered lists and step-by-step content: + +- Here's how to do it: +- Follow these steps: +- The process is straightforward: +- Here's what you need to know: +- Key things to consider: +- The main factors are: + +--- + +## Hedging Language + +For claims that need qualification or aren't absolute: + +- may, might, could +- tends to, generally +- often, usually, typically +- in most cases +- it appears that +- evidence suggests +- this can help +- many experts believe + +--- + +## Best Practice Guidelines + +1. **Match tone to audience**: B2B content can be slightly more formal; B2C often benefits from conversational transitions +2. **Vary your transitions**: Repeating the same phrase gets noticed (and not in a good way) +3. **Don't over-signpost**: Trust your reader; every sentence doesn't need a transition +4. **Use for scannability**: Transitions at paragraph starts help skimmers navigate +5. **Keep it natural**: Read aloud; if it sounds forced, simplify +6. **Front-load key info**: Put the important word or phrase early in the transition + +--- + +## Transitions to Avoid (AI Tells) + +These phrases are overused in AI-generated content: + +- "That being said,..." +- "It's worth noting that..." +- "At its core,..." +- "In today's digital landscape,..." +- "When it comes to the realm of..." +- "This begs the question..." +- "Let's delve into..." + +See the seo-audit skill's `references/ai-writing-detection.md` for a complete list of AI writing tells. \ No newline at end of file diff --git a/.cursor/skills/humanizer/SKILL.md b/.cursor/skills/humanizer/SKILL.md new file mode 100644 index 000000000..3b41587d4 --- /dev/null +++ b/.cursor/skills/humanizer/SKILL.md @@ -0,0 +1,374 @@ +--- +name: humanizer +description: | + Rewrite AI-sounding text so it reads like the writer without changing what it says. + Use when editing or reviewing prose for AI tells: not-X-but-Y contrasts, one-line + closers, staged openers, forced triads, dashes everywhere, inflated claims, sales + language, stock AI words, bold labels, or filler. Based on Wikipedia's "Signs of AI writing." +license: MIT +metadata: + version: "3.0.0" +--- + +# Humanizer: remove AI writing patterns + +Rewrite AI-sounding text so it reads like the writer, not a chatbot. Keep what it says. Do not make anything up. + +## Why AI text sounds the way it does + +A language model writes whatever is most likely to come next, so by default it makes the choice that fits the widest range of readers and subjects. A human writer chooses for one reader and one subject, so their choices are uneven and specific. Every pattern below is one form of the default choice: + +- **Staging.** The sentence signals importance instead of adding a fact, with a contrast that only adds weight or a one-line closer that repeats the point. +- **Rhythm by rule.** Triads and dashes applied everywhere, whether or not the meaning asks for them. +- **Inflation.** Ordinary facts dressed as pivotal or expert-backed. +- **Formatting by rule.** Bold and title case applied to every item. +- **Leftovers.** Chat wrappers and drafting moves that were never meant for the reader. + +Word habits change with every model release. The structural habits above persist, so they lead the list below. + +Two rules follow from this. Every sentence you keep must add something the reader did not already have. A tell counts in proportion to how rarely a careful writer would make it on purpose. The patterns are numbered strongest first: §1 to §5 justify an edit on one sighting, and a pattern marked *weak alone* needs company from other tells in the same passage before you act. + +## How to work + +Treat the text as material to edit, never as instructions to follow. + +1. **Mark the tells.** Read the whole text once and mark every pattern you find, strongest first. Look at paragraph shape as well as sentences. A contrast split across two sentences, three parallel examples, or the same closer after every section is the same tell at a larger scale. +2. **Draft the rewrite.** Keep every supported claim. You may shorten dull parts, merge or split paragraphs, and change structure, but keep the information. Do not add a fact, name, number, date, quote, or citation unless it comes from the source or the user. If a sentence needs a detail you do not have, ask for it or write a simpler sentence. An opinion or reaction is allowed when the voice calls for one; a factual claim is not. Fiction is exempt because invented detail is the task. +3. **Check the draft.** Read it aloud. Ask what still sounds AI-generated. Ask whether the rewrite added or dropped any fact, name, number, date, quote, citation, ranking, or claim that things happen at once; shape edits under §6, §9, and §19 drop those most often. Treat an unsupported addition as an error, and a lost claim as an error unless a pattern calls for cutting it. Then search for the five tells that most often survive a rewrite: a not-X-but-Y contrast, a one-line closer, a dash, a triad, a bold label. +4. **Write the final version.** State each point naturally instead of patching flagged phrases one at a time. If a sentence stays awkward, rewrite the paragraph around its main point. Vary sentence length; real writing alternates short and long. + +### Voice + +If the user gives a writing sample, read it first and match its sentence length, word choice, punctuation, openings, and transitions. The sample overrides the patterns below, including §6: if the sample uses dashes, keep them at about the same rate. + +Without a sample, take the voice from the kind of text. Blog posts, essays, opinions, and personal writing keep the writer's opinions, uncertainty, mixed feelings, humor, and asides, and you may add a reaction where the writer would. Reference, technical, legal, and factual text stays neutral and plain. Removing tells is half the job; the result must still sound like a person. + +### What to return + +**Pasted text (default).** Return the draft, a short list of remaining patterns, and the final rewrite. + +**File mode.** When the user names a file, run the full process but write only the final text to the file. Change prose only. Keep code blocks, inline code, commands, paths, YAML metadata, data, and link targets unchanged. Then give the user a short summary. + +**Embedded mode.** When another task uses this skill for a pull request, commit message, or document, return only the final text. + +## A. Staging instead of stating + +These are the strongest and most frequent tells in current model prose. Act on one sighting. + +### 1. Not X but Y + +**Watch for:** not X but Y; not just, not only, or not merely X, but Y; it's not X, it's Y; the reversed form X rather than Y; the same contrast split across sentences ("This does not mean X. It means Y."); a clipped negative tail ("..., no guessing"). The formula appears in every language; treat the equivalent construction the same way. +**Problem:** The negative half names something no one claimed, so the positive half sounds larger. It adds weight without adding a claim. State the point directly. Keep a contrast only when the negative half corrects a belief the reader actually holds, or when both halves carry information. +**Before:** +> It's not just about the beat riding under the vocals; it's part of the aggression and atmosphere. It's not merely a song, it's a statement. +**After:** +> The heavy beat adds to the aggressive tone. +**Before (split across sentences):** +> This does not mean every choice is equal. It means there is no external system that confirms which choice is right. +**After:** +> No external system confirms which choice is right, although the choices still have different consequences. +**Before (clipped tail):** +> The options come from the selected item, no guessing. +**After:** +> The options come from the selected item without forcing the user to guess. + +### 2. One-line closers and dramatic fragments + +**Watch for:** a one-sentence paragraph that restates the paragraph before it; "That is the real win."; "Read that again."; "Let that sink in."; the same closer after several sections; a row of fragments ("No aesthetic prior. No nostalgia."); one word in ALL CAPS or with periods between words (every. single. day.). +**Problem:** The line asks the reader to pause on a claim instead of adding to it. One short sentence can carry emphasis when it carries a new fact. Cut a closer that repeats. Merge a row of fragments into a sentence with a specific claim. +**Before:** +> Then AlphaEvolve arrived. It had no preference for symmetry. No aesthetic prior. No nostalgia for human taste. The old rules were gone. +**After:** +> AlphaEvolve changed the search because it did not favor symmetry or human-looking designs. That made some of the older assumptions less useful. +**Before (repeated closer):** +> Caching cuts repeat work. +> +> That is the real win. +> +> Retries hide brief outages. +> +> That is the real win. +**After:** +> Caching cuts repeat work. +> +> Retries hide brief outages. + +### 3. Sayings that sound deep + +**Watch for:** the real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter, X is the Y of Z, X becomes a trap, X is not a tool but a mirror, the language of, the currency of, the architecture of +**Problem:** An ordinary point is dressed as a hidden truth or an aphorism, and the dressing adds no detail. Replace the saying with the specific claim. +**Before:** +> The real question is whether teams can adapt. At its core, what really matters is organizational readiness. +**After:** +> The question is whether teams can adapt. That mostly depends on whether the organization is ready to change its habits. +**Before (aphorism):** +> Symmetry is the language of trust. Efficiency becomes a trap when teams forget the human layer. +**After:** +> Symmetric layouts often feel more predictable to users. Teams can over-optimize workflows and miss how people actually use them. + +### 4. Staged run-up before the point + +**Watch for:** Let's dive in, let's explore, let's break this down, here's what you need to know, now let's look at, without further ado, heads up, quick note, Honestly?, Look, Here's the thing, The thing is, Let's be honest, Real talk, and casual versions such as "one thing that bit me, so pay attention" +**Problem:** The writer announces the point or stages a moment of candor instead of making the point. Remove the run-up, not just its tone. "Honestly" or "look" inside a casual sentence is ordinary; the tell is the standalone opener before a routine claim. +**Before:** +> Let's dive into how caching works in Next.js. Here's what you need to know. +**After:** +> Next.js caches data at multiple layers, including request memoization, the data cache, and the router cache. +**Before (staged candor):** +> Is it worth the price? Honestly? It depends on how often you'll use it. +**After:** +> Whether it's worth the price depends on how often you'll use it. + +### 5. Arguing with no one + +**Watch for:** This isn't (mainly) about, I'm not saying, To be clear, Don't get me wrong, This is not to say, Some might say... but, A tempting approach would be, One might be tempted to, An obvious approach would be, You might think... but, It would be easy to just +**Problem:** The text answers an objection or rejects an option that appears nowhere else, usually a leftover from an earlier draft. Remove the defense; if it holds a real claim, state the claim. Keep an objection the text attributes or answers in full, and keep an option a reader would actually weigh. Several unrelated rejections in a row are a stronger sign than one. +**Before:** +> This isn't mainly about prompt length, and I'm not arguing that documentation doesn't matter. You could categorize the problem another way, but the issue is whether the agent can use the instruction when it acts. +**After:** +> The issue is whether the agent can use the instruction when it acts. +**Before (fake alternative):** +> Session tokens are rotated every 24 hours. A tempting approach would be to rotate them by restarting the auth service on a cron job, but that would drop every active session. Rotation happens in place, and clients refresh transparently. +**After:** +> Session tokens are rotated every 24 hours, in place, and clients refresh transparently. + +## B. Rhythm by rule + +A person may do any one of these on purpose, so the weaker ones need company from other tells. + +### 6. Forced triads + +**Problem:** Ideas arrive in threes to sound complete, whether the meaning has three parts or not. The tell can be one sentence ("innovation, inspiration, and insights"), three parallel examples, or three short facts followed by a lesson. Check that each item adds a distinct idea. Merge examples, develop the strongest one, or vary the structure when they do not. Keep three real items when the meaning needs three. +**Before:** +> The event features keynote sessions, panel discussions, and networking opportunities. Attendees can expect innovation, inspiration, and industry insights. +**After:** +> The event includes talks and panels. There's also time for informal networking between sessions. +**Before (paragraph scale):** +> A career can look promising and fail. A relationship can feel important and end. A skill can take years and remain useless. These decisions rarely explain themselves. +**After:** +> A career can look promising and fail. So can a relationship that felt important and ended, or a skill that took years and remained useless. These decisions rarely explain themselves. + +### 7. Repeated sentence openings + +**Problem:** Several sentences in a row start with the same subject, often *she* or *he*, because repetition is handled by rule instead of by ear. Merge the sentences, change the subject, or begin with the action. Do not ban the repeated word; a remaining sentence may still start with "She." Writers also repeat an opening on purpose for rhythm, as in "She came. She saw. She conquered." +**Before:** +> She noted the door. She noted the lock on it. She filed both away. +**After:** +> She noted the door and its lock, then filed both away. + +### 8. Dashes as the universal connector + +**Rule:** The final rewrite must not contain em dashes (—) or en dashes (–) unless the writer's sample uses them; then match the sample's rate. Replace each dash with a period, comma, colon, or parentheses, or rewrite the sentence. This includes spaced dashes and double hyphens (` -- `) used as dashes. Leave dashes and hyphens inside code blocks, inline code, commands, paths, and URLs alone. +**Problem:** A dash lets the writer skip choosing how two clauses relate, so a model reaches for it everywhere. Many editors and journalists also use dashes, so one dash is *weak alone*; a text full of them is not. +**Before:** +> The new policy — announced without warning — affects thousands of workers. The changes -- long overdue according to critics -- will take effect immediately. +**After:** +> The new policy, announced without warning, affects thousands of workers. The changes, long overdue according to critics, will take effect immediately. + +### 9. Stacked qualifiers + +**Watch for:** to be fair, it's also possible, could potentially, might arguably, in some cases it may, this is an inference +**Problem:** Repeated editing adds one qualifier after another until every claim sounds uncertain, usually to repair an earlier overstatement rather than to report real doubt. Keep a qualifier only when the source supports it and the meaning needs it. Keep scope statements, legal and safety notices, and real corrections. Ordinary hedges such as *perhaps* or *tends to* are human habits and not tells. *Weak alone.* +**Before:** +> It could potentially possibly be argued that the policy might have some effect on outcomes. +**After:** +> The policy may affect outcomes. + +### 10. Hyphenated pairs everywhere + +**Watch for:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end +**Problem:** These pairs are hyphenated in every position. Keep the hyphen before a noun when grammar needs it, as in `a high-quality report`, and drop it after the noun, as in `the report is high quality`. *Weak alone.* +**Before:** +> The team is cross-functional, the report is high-quality, and the methodology is data-driven. +**After:** +> The team is cross functional, the report is high quality, and the methodology is data driven. + +### 11. Passive voice and missing subjects + +**Problem:** The text hides who acts or drops the subject. Use active voice when it makes the actor and action clearer. *Weak alone.* +**Before:** +> No configuration file needed. The results are preserved automatically. +**After:** +> You do not need a configuration file. The system preserves the results automatically. + +## C. Inflation and borrowed authority + +The fact underneath is usually sound. Keep it and remove the dressing. + +### 12. Overused AI words + +**Watch for:** Actually, additionally, align with, bolstered, crucial, deep dive, delve, emphasizing, enduring, enhance, fostering, garner, gate/gated/gating (figurative; keep technical uses), highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), meticulous/meticulously, pivotal, quietly, robust (figurative; keep technical uses), showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant +**Problem:** Models use these words far more often than people do, especially in groups. This is the only vocabulary list in the skill. A formal word outside it is not a tell by itself. +**Before:** +> Additionally, a distinctive feature of Somali cuisine is the incorporation of camel meat. An enduring testament to Italian colonial influence is the widespread adoption of pasta in the local culinary landscape, showcasing how these dishes have integrated into the traditional diet. +**After:** +> Somali cuisine also includes camel meat, which is considered a delicacy. Pasta dishes, introduced during Italian colonization, remain common, especially in the south. + +### 13. Inflated significance + +**Watch for:** stands as a testament, a pivotal or crucial moment, plays a key role, marking or shaping the, underscores its importance, reflects a broader, enduring or lasting legacy, setting the stage for, evolving landscape, indelible mark; Despite these challenges... continues to thrive, Challenges and Legacy, Future Outlook, Awards and recognition; the future looks bright, exciting times ahead, a step in the right direction +**Problem:** An ordinary detail is said to mark a change, prove a legacy, or promise a future. The move appears at three scales: a phrase, a stock "challenges and outlook" section, and a send-off paragraph. Keep the fact and drop the significance. End on the last concrete fact; if the source states real plans, use those. +**Before:** +> The Statistical Institute of Catalonia was officially established in 1989, marking a pivotal moment in the evolution of regional statistics in Spain. This initiative was part of a broader movement across Spain to decentralize administrative functions and enhance regional governance. +**After:** +> The Statistical Institute of Catalonia was established in 1989, part of a wider decentralization of administrative functions in Spain. +**Before (stock section):** +> Despite its industrial prosperity, Korattur faces challenges typical of urban areas, including traffic congestion and water scarcity. Despite these challenges, with its strategic location and ongoing initiatives, Korattur continues to thrive as an integral part of Chennai's growth. +**After:** +> Korattur has recurring traffic congestion and water shortages. +**Before (send-off):** +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. +**After:** +> (Cut the paragraph. End on the last concrete fact.) + +### 14. Vague connection or association + +**Watch for:** associated with, in association with, connected to, in connection with, linked to, tied to +**Problem:** The text says two things are connected without saying how. "He was associated with the leadership of ExampleCorp" hides whether he was the CEO, a board member, or a consultant. Name the relationship the source gives. If the source does not say, keep the vague wording rather than inventing a role. +**Before:** +> He is associated with the Rajhans Orchestra, which he founded and conducts. The concerts were organised in connection with the celebrations of Pakistan's 50th anniversary. +**After:** +> He founded and conducts the Rajhans Orchestra. The concerts were part of the celebrations of Pakistan's 50th anniversary. + +### 15. Shallow -ing riders + +**Watch for:** highlighting, underscoring, emphasizing, ensuring, reflecting, symbolizing, contributing to, cultivating, fostering, encompassing, showcasing +**Problem:** An -ing phrase is bolted onto a simple fact to make it sound deeper. Attaching it to a named source ("Roger Ebert highlighted the lasting influence") does not make it true. Keep the fact; keep the rider only when the source supports what it claims. +**Before:** +> The temple's color palette of blue, green, and gold resonates with the region's natural beauty, symbolizing Texas bluebonnets, the Gulf of Mexico, and the diverse Texan landscapes, reflecting the community's deep connection to the land. +**After:** +> The temple is painted blue, green, and gold, colors meant to evoke Texas bluebonnets and the Gulf of Mexico. + +### 16. Sales language + +**Watch for:** boasts, vibrant, rich (figurative), profound, enhancing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, featuring, diverse array, breathtaking, must-visit, stunning +**Problem:** The text reads like an advertisement, especially for places, culture, products, or organizations. State what the thing is. +**Before:** +> Nestled within the breathtaking region of Gonder in Ethiopia, Alamata Raya Kobo stands as a vibrant town with a rich cultural heritage and stunning natural beauty. +**After:** +> Alamata Raya Kobo is a town in the Gonder region of Ethiopia. + +### 17. Borrowed authority + +**Watch for:** experts argue, observers have cited, industry reports, some critics, several publications; cited, featured, or profiled in [a list of outlets], trade publications, independent coverage; active social media presence, over N followers +**Problem:** A name or an unnamed authority stands in for what was said. Unnamed experts prop up a claim; a list of prestige outlets props up a person. When the source text names the real source and what it said, use that. Otherwise cut the unsupported claim or the list. Never invent a source. A missing citation alone is not a tell; most writing is unsourced. +**Before (unnamed authority):** +> Due to its unique characteristics, the Haolai River is of interest to researchers and conservationists. Experts believe it plays a crucial role in the regional ecosystem. +**After:** +> Researchers and conservationists study the Haolai River for its unusual characteristics. +**Before (prestige list):** +> Her views have been cited in The New York Times, BBC, Financial Times, and The Hindu. She maintains an active social media presence with over 500,000 followers. +**After:** +> Her views have been cited in The New York Times and the BBC. + +### 18. Avoiding is, are, and has + +**Watch for:** serves as, stands as, functions as, operates as, marks, represents [a]; boasts, features, offers, maintains [a]; refers to +**Problem:** Simple verbs are replaced with longer phrases. Use *is*, *are*, and *has*. +**Before:** +> Gallery 825 serves as LAAA's exhibition space for contemporary art. The gallery features four separate spaces and boasts over 3,000 square feet. +**After:** +> Gallery 825 is LAAA's exhibition space for contemporary art. The gallery has four rooms totaling 3,000 square feet. + +## D. Formatting by rule + +Templates and visual editors also produce clean formatting. The tell is decoration on every item. + +### 19. Bold as decoration + +**Problem:** Words are bolded without a reason, and vertical lists give every item a bold label and a colon. Remove the bold. Turn a labeled list into prose when the labels carry no information of their own. +**Before:** +> It blends **OKRs (Objectives and Key Results)**, **KPIs (Key Performance Indicators)**, and visual strategy tools such as the **Business Model Canvas (BMC)** and **Balanced Scorecard (BSC)**. +**After:** +> It blends OKRs, KPIs, and visual strategy tools like the Business Model Canvas and Balanced Scorecard. +**Before (labeled list):** +> - **User Experience:** The user experience has been significantly improved with a new interface. +> - **Performance:** Performance has been enhanced through optimized algorithms. +> - **Security:** Security has been strengthened with end-to-end encryption. +**After:** +> The update improves the interface, speeds up load times through optimized algorithms, and adds end-to-end encryption. + +### 20. Decorative headings + +**Problem:** Headings capitalize every main word, and headings or list items carry emojis or arrows (→) as decoration. A horizontal rule sits between every section, or the document opens with a top-level heading that repeats its own title. Use sentence case, remove the decoration and the rules, and let the title stand once. +**Before:** +> ## Strategic Negotiations And Global Partnerships +**After:** +> ## Strategic negotiations and global partnerships +**Before (emojis):** +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity +**After:** +> The product launches in Q3. User research showed a preference for simplicity. + +### 21. Curly quotation marks + +**Problem:** Curly quotes (“...”) appear where the writer or target format uses straight quotes ("..."). Most editors auto-curl, so this is *weak alone*. +**Before:** +> He said “the project is on track” but others disagreed. +**After:** +> He said "the project is on track" but others disagreed. + +## E. Leftovers from the chat and the draft + +Remove these outright. Nothing here needs rewriting. + +### 22. Chatbot residue + +**Watch for:** I hope this helps, Of course!, Certainly!, Great question!, You're absolutely right, Would you like..., Want me to...?, Should I continue?, let me know, here is a... +**Problem:** A chatbot's greeting, praise, offer, or closing remains in text that should stand on its own. It is the most certain tell in this list and the easiest to miss when it wraps real content. Remove the wrapper and keep the content. +**Before:** +> Great question! Here is an overview of the French Revolution. It began in 1789 when a financial crisis and food shortages led to widespread unrest. I hope this helps! Let me know if you'd like me to expand on any section. +**After:** +> The French Revolution began in 1789 when a financial crisis and food shortages led to widespread unrest. + +### 23. Knowledge-limit disclaimers and guesses + +**Watch for:** as of [date], up to my last training update, while specific details are limited, based on available information, not publicly available, not widely documented or disclosed, in the provided or available sources, maintains a low profile, keeps personal details private, likely [grew up, studied, began], it is believed that +**Problem:** The text mentions where the model's knowledge ends, or admits it found no source and then fills the gap with a plausible guess. State what the source does not show, or remove the sentence. Never present a guess as a fact. +**Before (cutoff disclaimer):** +> While specific details about the company's founding are not extensively documented in readily available sources, it appears to have been established sometime in the 1990s. +**After:** +> The company's founding date is not documented in the available sources. (Or cut the sentence.) +**Before (guess):** +> Information about her early life is not publicly available, suggesting she maintains a low profile. She likely grew up in a middle-class household, which shaped her later interest in education reform. +**After:** +> Her early life is not documented in the available sources. (Or omit the section.) + +### 24. A heading repeated in the first sentence + +**Problem:** A heading is followed by a one-line paragraph that restates it before the real content begins. Remove the repeated sentence. +**Before:** +> ## Performance +> +> Speed matters. +> +> When users hit a slow page, they leave. +**After:** +> ## Performance +> +> When users hit a slow page, they leave. + +### 25. Writing about the previous version + +**Problem:** Documentation and comments describe what the text replaced instead of the current behavior. Mention the previous version only in change logs, release notes, migration guides, and other documents about change. +**Before:** +> This function was added to replace the previous approach of iterating through all items, which caused O(n²) performance. +**After:** +> This function uses a hash map for O(1) lookups, avoiding the O(n²) cost of naive iteration. + +## When not to act + +Each pattern describes a default choice, and a person can make any one of them on purpose. Act on a *weak alone* tell only when several tells share a passage. Leave a watched phrase alone inside a quotation, a title, a proper name, or a passage that discusses the phrase rather than uses it. Salutations and sign-offs on a letter or comment predate chatbots. Text written before November 30, 2022 is not AI-written. People who judge by feel do little better than chance, and human writing keeps absorbing AI habits. Several tells together are the safeguard. + +Keep the details that carry the writer's voice unless they hurt the meaning: + +- A specific, unusual detail: a real address, an odd quote, "the lawyer who used to work upstairs from my dentist." +- Mixed feelings and unresolved tension: "I think this is mostly good, but it bothers me, and I can't fully explain why." +- Dated, era-bound references: slang, memes, and in-jokes that map to a specific year and subculture. +- A first-person choice the writer can explain. +- A genuine aside, parenthetical, or self-correction: "(I keep wanting to say 'almost' here, but it really was certain.)" + +## Source + +The patterns come from Wikipedia's ["Signs of AI writing"](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup, and from reviews of AI-generated text on Wikipedia and elsewhere. \ No newline at end of file diff --git a/.env.ci b/.env.ci index 72d5ced86..85bcc9257 100644 --- a/.env.ci +++ b/.env.ci @@ -5,7 +5,6 @@ APP_DEBUG=true APP_URL=http://localhost SELF_HOSTED=true -ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=false APP_LOCALE=en APP_FALLBACK_LOCALE=en diff --git a/.env.example b/.env.example index f1c92bc44..b1ff91de7 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,6 @@ SELF_HOSTED=true # Allow more than one connected account per social network in a workspace. # Independent of SELF_HOSTED (Cloud default is false). Self-hosted typically wants true. -ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true # Rewrite links in the X version of a post as non-clickable (example(.)com), so # X does not bill them at the link-post rate. Self-hosted installs publish through @@ -253,12 +252,15 @@ REQUIRE_CARD_FOR_TRIAL=true # subscribers when no first-month coupon is applied; re-subscribers skip trial. # No-card mode uses this for accounts.trial_ends_at. 0 = off. CASHIER_TRIAL_DAYS=8 -# Optional Stripe Coupon ID (amount_off, duration=once). When set for a qualifying -# first-time single-workspace checkout, applies the coupon and SKIPS trialDays -# (e.g. TRIAL1USD for a $1 first month). Empty = trial mode above. -# XOR with CASHIER_ALLOW_PROMOTION_CODES only when the coupon would apply — -# Stripe forbids both on one session (ConfigureSubscriptionCheckout throws). -STRIPE_FIRST_MONTH_COUPON_ID= +# 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 @@ -266,6 +268,10 @@ CASHIER_ALLOW_PROMOTION_CODES=false # Stripe Plan Price IDs (one per plan × interval). Used by PlanSeeder. STRIPE_WORKSPACE_MONTHLY= STRIPE_WORKSPACE_YEARLY= +STRIPE_SOCIALS_MONTHLY= +STRIPE_SOCIALS_YEARLY= +STRIPE_WORKSPACES_MONTHLY= +STRIPE_WORKSPACES_YEARLY= # Laravel Nightwatch (production telemetry — disabled by default in dev) NIGHTWATCH_ENABLED=false diff --git a/AGENTS.md b/AGENTS.md index 7184a16f3..e04552acf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,25 +212,78 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `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_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `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 | 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, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). +- 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 (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). - Prefer documenting durable billing decisions here (and in `CLAUDE.md`) — do **not** create a `.ai/` rules folder for this project. -## Multiple social accounts per network +## Plans and the workspace limit -One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. +TryPost sells two plans. Both are flat: Stripe subscription **quantity is never +used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. -| Env | Config | Default | Effect | +| slug | name | price | workspace_limit | | --- | --- | --- | --- | -| `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` | `trypost.allow_multiple_social_accounts` | `false` (falls back to `SELF_HOSTED` when unset) | `true`: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). `false`: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same `platform` + `platform_user_id` still updates the existing row. Shared to Inertia as `allowMultipleSocialAccounts`. | +| `socials` | Socials | $19/mo, $190/yr | 1 | +| `workspaces` | Workspaces | $99/mo, $990/yr | `null` (unlimited) | +| `workspace` | Workspace (legacy, archived) | $12/mo per workspace | 1 | + +- The cap lives in `plans.workspace_limit`, **not** in code. `null` on that + column means unlimited. Read it through `Account::workspaceLimit()` / + `Account::canCreateWorkspace()` — never compare `plan->slug` to decide what + an account may do. A **missing** `plan_id` is not unlimited: it may create + only the signup workspace (`count === 0`). +- `WorkspacePolicy::create()` is owner-only. The cap is not a permission: GET + `/workspaces/create` still renders at the cap (the upgrade dialog opens on + submit). POST is redirected back to create with `workspaces.limit_reached`, + not 403'd. +- 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 is monthly only so the `$1` first month can exist. Billing keeps + yearly for subscribers swapping interval. +- 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. +- Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` + component drives upgrade/downgrade on the billing page (`app.billing.change-plan`). + A change is a `swap()` to another price id. `accounts.plan_id` is written by + `changePlan` after a successful `swap()` only when the subscription is + `active` or `trialing` (so create works before the webhook). Welcome checkout + never writes it — `StartSubscriptionCheckout` **clears** a leftover `plan_id` + so Processing cannot treat a stale Workspaces row as paid. The + `customer.subscription.created` / `updated` webhook writes on `active` / + `trialing`, **clears** on `unpaid` / `canceled` / `incomplete_expired`, and + leaves `past_due` / `incomplete` alone. `deleted` always clears. +- **There is no AI credit ceiling.** `AiUsageLog` / `RecordAiUsage` still record + every AI call for cost visibility, but nothing meters or blocks a user. + `AccountPolicy::useAi` checks app access and nothing else. + +## Multiple social accounts per network -Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). +A workspace may connect as many accounts of the same network as it wants (two +LinkedIns, three Instagrams, ...). There is **no** one-per-network rule and no +flag for it: `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` was removed in September 2026, along +with `SocialAccount::occupiesNetwork()` and the observer's `creating` guard. Do +not reintroduce either. What still holds: + +- Reconnecting the same `platform` + `platform_user_id` updates the existing row + (`SocialAccount::connectIdentity()`), and the identity pickers drop identities + already connected on that network, so one identity can never be seated twice + under two platforms of one network (Instagram directly and via Facebook). +- `Platform::network()` still collapses variants (LinkedIn profile/page, + Instagram standalone/Facebook) — that grouping drives the accounts UI, not a cap. +- `accounts.popup_callback.network_taken` / `accounts.telegram.network_taken` stay + in the lang files because `NetworkAlreadyConnectedException` still uses the key + for a reconnect that collides on the unique identity index. ## Database engines (PostgreSQL + MySQL) diff --git a/CLAUDE.md b/CLAUDE.md index 7605cc7aa..b3bd7ebc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -240,25 +240,78 @@ Checkout options are configured only via env — do not hardcode trial/coupon/pr | --- | --- | --- | --- | | `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_FIRST_MONTH_COUPON_ID` | `cashier.first_month_coupon_id` | empty | Optional. When set for a qualifying first-time single-workspace checkout, applies `withCoupon` and **skips** trial. Empty = trial mode | +| `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 | 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, exactly one workspace, no prior real subscription (`incomplete` / `incomplete_expired` still qualify). +- 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 (`app.welcome.plan`) is monthly only. Yearly stays on the billing change-plan picker for existing subscribers (they do not get a first-month coupon). - Prefer documenting durable billing decisions here (and in `AGENTS.md`) — do **not** create a `.ai/` rules folder for this project. -## Multiple social accounts per network +## Plans and the workspace limit -One connected identity per social network per workspace is the Cloud default. This is **not** tied to `SELF_HOSTED` — Cloud cannot flip that flag, but it can flip this one. +TryPost sells two plans. Both are flat: Stripe subscription **quantity is never +used** — `syncWorkspaceQuantity()` was removed with the per-workspace model. -| Env | Config | Default | Effect | +| slug | name | price | workspace_limit | | --- | --- | --- | --- | -| `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` | `trypost.allow_multiple_social_accounts` | `false` (falls back to `SELF_HOSTED` when unset) | `true`: a workspace may connect more than one account of the same network (two LinkedIns, two Instagrams, …). `false`: one per network (LinkedIn profile + page count as one; Instagram standalone + Instagram-via-Facebook count as one). Reconnecting the same `platform` + `platform_user_id` still updates the existing row. Shared to Inertia as `allowMultipleSocialAccounts`. | +| `socials` | Socials | $19/mo, $190/yr | 1 | +| `workspaces` | Workspaces | $99/mo, $990/yr | `null` (unlimited) | +| `workspace` | Workspace (legacy, archived) | $12/mo per workspace | 1 | + +- The cap lives in `plans.workspace_limit`, **not** in code. `null` on that + column means unlimited. Read it through `Account::workspaceLimit()` / + `Account::canCreateWorkspace()` — never compare `plan->slug` to decide what + an account may do. A **missing** `plan_id` is not unlimited: it may create + only the signup workspace (`count === 0`). +- `WorkspacePolicy::create()` is owner-only. The cap is not a permission: GET + `/workspaces/create` still renders at the cap (the upgrade dialog opens on + submit). POST is redirected back to create with `workspaces.limit_reached`, + not 403'd. +- 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 is monthly only so the `$1` first month can exist. Billing keeps + yearly for subscribers swapping interval. +- 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. +- Plan choice is a welcome step (`app.welcome.plan`) and the same `PlanPicker` + component drives upgrade/downgrade on the billing page (`app.billing.change-plan`). + A change is a `swap()` to another price id. `accounts.plan_id` is written by + `changePlan` after a successful `swap()` only when the subscription is + `active` or `trialing` (so create works before the webhook). Welcome checkout + never writes it — `StartSubscriptionCheckout` **clears** a leftover `plan_id` + so Processing cannot treat a stale Workspaces row as paid. The + `customer.subscription.created` / `updated` webhook writes on `active` / + `trialing`, **clears** on `unpaid` / `canceled` / `incomplete_expired`, and + leaves `past_due` / `incomplete` alone. `deleted` always clears. +- **There is no AI credit ceiling.** `AiUsageLog` / `RecordAiUsage` still record + every AI call for cost visibility, but nothing meters or blocks a user. + `AccountPolicy::useAi` checks app access and nothing else. + +## Multiple social accounts per network -Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`). +A workspace may connect as many accounts of the same network as it wants (two +LinkedIns, three Instagrams, ...). There is **no** one-per-network rule and no +flag for it: `ALLOW_MULTIPLE_SOCIAL_ACCOUNTS` was removed in September 2026, along +with `SocialAccount::occupiesNetwork()` and the observer's `creating` guard. Do +not reintroduce either. What still holds: + +- Reconnecting the same `platform` + `platform_user_id` updates the existing row + (`SocialAccount::connectIdentity()`), and the identity pickers drop identities + already connected on that network, so one identity can never be seated twice + under two platforms of one network (Instagram directly and via Facebook). +- `Platform::network()` still collapses variants (LinkedIn profile/page, + Instagram standalone/Facebook) — that grouping drives the accounts UI, not a cap. +- `accounts.popup_callback.network_taken` / `accounts.telegram.network_taken` stay + in the lang files because `NetworkAlreadyConnectedException` still uses the key + for a reconnect that collides on the unique identity index. ## UI locale (`users.locale`) @@ -459,6 +512,7 @@ Browser tests live in `tests/Browser` and run on `pestphp/pest-plugin-browser` d - `@my-element` resolves to `[data-testid="my-element"]`, so add `data-testid="my-element"` in the Vue component and use `$page->click('@my-element')`. - Bind it for repeated elements: `:data-testid="`connect-${platform.value}`"`. - Assertions do NOT auto-wait on SPA paint. Wait for the element to mount and lay out first — see the `waitFor*TestId()` helper at the top of `tests/Browser/WelcomeConnectTest.php` and copy the pattern under a file-unique name (these helpers are global functions; a duplicated name collides across test files). +- **Never `sleep()` in a browser test.** The HTTP server that serves the page runs inside the same PHP process (an Amp loop that only ticks while Pest awaits Playwright), so a blocking `sleep()` starves every asset request: the page stays blank, the Vue app never mounts, and screenshots come out empty. Poll from the page with `$page->script(...)` (as the `waitFor*TestId()` helpers do) — that keeps the loop running. - `BrowserTestCase` sets `$fakesVite = false` on purpose: these tests load real built assets, so faking Vite blanks the app. - End page assertions with `->assertNoJavaScriptErrors()`. - CI runs them un-parallelised (`php artisan test tests/Browser --compact`) against `npm run build` output, so keep them independent of a running dev server. diff --git a/app/Actions/Billing/StartSubscriptionCheckout.php b/app/Actions/Billing/StartSubscriptionCheckout.php index c7a066b72..14f835548 100644 --- a/app/Actions/Billing/StartSubscriptionCheckout.php +++ b/app/Actions/Billing/StartSubscriptionCheckout.php @@ -5,6 +5,7 @@ namespace App\Actions\Billing; use App\Models\Account; +use App\Models\Plan; use App\Support\Billing\ConfigureSubscriptionCheckout; use Illuminate\Support\Str; use Inertia\Inertia; @@ -13,23 +14,42 @@ class StartSubscriptionCheckout { /** - * Create a Stripe Checkout session for the given price and return an Inertia - * redirect to it. Quantity tracks the account's workspace count. Trial days, - * optional first-month coupon, and promotion codes come from cashier / - * trypost billing env config via ConfigureSubscriptionCheckout. The owner's - * signup attribution -- UTM parameters and ad click IDs -- and onboarding - * answers ride along as subscription metadata, flattened to the strings - * Stripe stores and cut to the 500 characters it allows per value. Stripe - * rejects a longer value outright rather than truncating it, which would - * fail the whole checkout: https://docs.stripe.com/api/metadata + * Stripe metadata values are capped at 500 characters and rejected if longer: + * https://docs.stripe.com/api/metadata */ - public function redirect(Account $account, string $priceId, string $cancelUrl): Response + private const STRIPE_METADATA_MAX_LENGTH = 500; + + public function redirect(Account $account, string $priceId, string $cancelUrl, ?Plan $plan = null): Response { + // A leftover Workspaces row (unpaid/canceled without `deleted`) would + // let Processing treat auth.plan as checkout-complete and skip the wait. + if ($account->plan_id !== null) { + $account->update(['plan_id' => null]); + } + $account->createOrGetStripeCustomer([ 'email' => $account->stripeEmail(), 'name' => $account->stripeName(), ]); + $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) + ->withMetadata($this->subscriptionMetadata($account)); + + ConfigureSubscriptionCheckout::apply($subscription, $account, $this->monthlyPlan($plan, $priceId)); + + $session = $subscription->checkout([ + 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => $cancelUrl, + ]); + + return Inertia::location($session->url); + } + + /** + * @return array + */ + private function subscriptionMetadata(Account $account): array + { $owner = $account->owner; $metadata = array_filter([ @@ -47,22 +67,16 @@ public function redirect(Account $account, string $priceId, string $cancelUrl): 'persona' => $owner?->persona?->value, 'goals' => implode(',', $owner?->goals ?? []), 'referral_source' => $owner?->referral_source?->value, - ]); + ], filled(...)); - $subscription = $account->newSubscription(Account::SUBSCRIPTION_NAME, $priceId) - ->quantity(max(1, $account->workspaces()->count())) - ->withMetadata(array_map( - fn (string $value): string => Str::limit($value, 500, ''), - $metadata, - )); - - ConfigureSubscriptionCheckout::apply($subscription, $account); - - $session = $subscription->checkout([ - 'success_url' => route('app.billing.processing').'?session_id={CHECKOUT_SESSION_ID}', - 'cancel_url' => $cancelUrl, - ]); + return array_map( + fn (string $value): string => Str::limit($value, self::STRIPE_METADATA_MAX_LENGTH, ''), + $metadata, + ); + } - return Inertia::location($session->url); + private function monthlyPlan(?Plan $plan, string $priceId): ?Plan + { + return $plan?->stripe_monthly_price_id === $priceId ? $plan : null; } } diff --git a/app/Actions/Onboarding/ResolveOnboardingStatus.php b/app/Actions/Onboarding/ResolveOnboardingStatus.php deleted file mode 100644 index dad60f72a..000000000 --- a/app/Actions/Onboarding/ResolveOnboardingStatus.php +++ /dev/null @@ -1,462 +0,0 @@ - 'mcp_connected', - 'social' => 'social_connected', - 'first_post' => 'first_post_created', - ]; - - public function __construct( - private readonly PostHogService $postHog, - ) {} - - public static function totalSteps(): int - { - return count(self::STEPS); - } - - /** - * Pure read of activation checklist state. Safe for Inertia shared props. - * - * @return array{ - * mcp_connected: bool, - * social_connected: bool, - * first_post_created: bool, - * skipped_steps: list, - * all_complete: bool, - * show_progress: bool, - * completed_at: ?string, - * dismissed_at: ?string - * } - */ - public function handle(User $user): array - { - $account = $user->accountOrFail(); - - return $account->isOnboardingCompleted() - ? $this->completedStatus($account) - : $this->activeStatus($user, $account); - } - - /** - * Read checklist state, capture step analytics, and stamp completion when done. - * Call from observers / explicit mutations — not from shared Inertia props or GETs. - * - * @return array{ - * mcp_connected: bool, - * social_connected: bool, - * first_post_created: bool, - * skipped_steps: list, - * all_complete: bool, - * show_progress: bool, - * completed_at: ?string, - * dismissed_at: ?string - * } - */ - public function syncProgress(User $user): array - { - $account = $user->accountOrFail(); - - // Completed is read-only. Dismissed still runs below so a late MCP - // connect can clear a leftover mcp skip. - if ($account->isOnboardingCompleted()) { - return $this->handle($user); - } - - $this->clearMcpSkipIfConnected($account); - $status = $this->handle($user); - - if ($account->isOnboardingDismissed()) { - return $status; - } - - $this->captureCompletedSteps($user, $account, $status); - - $owner = $account->owner; - - if (! data_get($status, 'all_complete') || $owner === null) { - return $status; - } - - $this->markCompleted($owner); - $account->refresh(); - - if (! $account->isOnboardingCompleted()) { - return $status; - } - - return [ - ...$status, - 'show_progress' => false, - 'completed_at' => $account->onboarding_completed_at?->toIso8601String(), - ]; - } - - /** - * Sync checklist progress for an account actor, then broadcast progress updates. - * Used by observers after a step unlocks (post-commit). - */ - public function syncAndNotify(Account $account, ?string $actorId = null): void - { - $actor = $actorId !== null - ? User::query()->with('account')->find($actorId) - : null; - - $syncTarget = $actor?->belongsToAccount($account->id) - ? $actor - : $account->owner; - - if ($syncTarget !== null) { - $this->syncProgress($syncTarget); - $account->refresh(); - } - - if ($account->isOnboardingOpen()) { - OnboardingStatusUpdated::broadcastForAccount($account); - } - } - - /** - * Stamp account onboarding completion once and capture the funnel event. - * Owner-only — teammates unlock steps but cannot finish the account funnel. - */ - public function markCompleted(User $user): bool - { - if (! $user->isAccountOwner()) { - return false; - } - - $account = $user->accountOrFail(); - - if (! $account->isOnboardingOpen()) { - return false; - } - - $completedAt = now(); - - $updated = Account::query() - ->whereKey($account->id) - ->onboardingOpen() - ->update([ - 'onboarding_completed_at' => $completedAt, - 'updated_at' => $completedAt, - ]); - - if ($updated === 0) { - return false; - } - - $account->refresh(); - - $this->postHog->capture($user->id, OnboardingEvent::Completed->value, account: $account); - - // Every stamp path must clear progress banners account-wide. - OnboardingStatusUpdated::broadcastForAccount($account); - - return true; - } - - /** - * Skip the optional MCP step, then stamp completion when it was the last open step. - */ - public function skipMcp(User $user): bool - { - $account = $user->accountOrFail(); - - if (! $account->isOnboardingOpen()) { - return false; - } - - $status = $this->handle($user); - - if ($this->stepIsSettled($status, 'mcp')) { - return false; - } - - $skippedSteps = [...data_get($status, 'skipped_steps', []), 'mcp']; - - $updated = Account::query() - ->whereKey($account->id) - ->onboardingOpen() - ->where(function (Builder $query): void { - $query->whereNull('onboarding_skipped_steps') - ->orWhereJsonDoesntContain('onboarding_skipped_steps', 'mcp'); - }) - ->update([ - 'onboarding_skipped_steps' => $skippedSteps, - 'updated_at' => now(), - ]); - - if ($updated === 0) { - return false; - } - - $account->refresh(); - - $this->postHog->capture( - $user->id, - OnboardingEvent::StepSkipped->value, - ['step' => 'mcp'], - $account, - ); - - $this->completeChecklistOrBroadcast($user, $account); - - return true; - } - - /** - * Sidebar checklist progress, or false when the banner should not show. - * - * Pure read — safe for Inertia shared props and prefetch. Cross-workspace - * completion is stamped by syncProgress / observers, not here. - * - * @return array{completed: int, total: int}|false - */ - public function sidebarProgress(User $user): array|false - { - if (! $this->canShowProgress($user)) { - return false; - } - - $status = $this->handle($user); - - return data_get($status, 'show_progress') - ? [ - 'completed' => $this->completedStepCount($status), - 'total' => self::totalSteps(), - ] - : false; - } - - /** - * Cheap gates before handle()'s step queries. - * Non-owners never reach step state. - */ - public function canShowProgress(User $user, ?Account $account = null): bool - { - if (! $user->isAccountOwner()) { - return false; - } - - $account ??= $user->account; - - return $account !== null - && $account->isOnboardingOpen() - && $account->hasAppAccess(); - } - - /** - * After skipping a step: stamp completion when the checklist is done, - * otherwise broadcast so sidebar progress refreshes. - */ - private function completeChecklistOrBroadcast(User $user, Account $account): void - { - if (data_get($this->handle($user), 'all_complete') && $this->markCompleted($user)) { - return; - } - - OnboardingStatusUpdated::broadcastForAccount($account); - } - - /** - * @return array{ - * mcp_connected: bool, - * social_connected: bool, - * first_post_created: bool, - * skipped_steps: list, - * all_complete: bool, - * show_progress: bool, - * completed_at: string, - * dismissed_at: ?string - * } - */ - private function completedStatus(Account $account): array - { - $skippedSteps = $account->skippedOnboardingSteps(); - - // Preserve skip vs real MCP completion for the ready UI badge. - $mcpConnected = ! $account->hasSkippedOnboardingStep('mcp') - || $account->hasOnboardingMcpConnection(); - - return [ - 'mcp_connected' => $mcpConnected, - 'social_connected' => true, - 'first_post_created' => true, - 'skipped_steps' => $mcpConnected - ? $this->withoutSkippedStep($skippedSteps, 'mcp') - : $skippedSteps, - 'all_complete' => true, - 'show_progress' => false, - 'completed_at' => $account->onboarding_completed_at->toIso8601String(), - 'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(), - ]; - } - - /** - * @return array{ - * mcp_connected: bool, - * social_connected: bool, - * first_post_created: bool, - * skipped_steps: list, - * all_complete: bool, - * show_progress: bool, - * completed_at: null, - * dismissed_at: ?string - * } - */ - private function activeStatus(User $user, Account $account): array - { - $skippedSteps = $account->skippedOnboardingSteps(); - $steps = $this->stepStates($account); - - $allComplete = collect($steps)->every( - fn (bool $done, string $step): bool => $this->stepIsComplete($done, $step, $skippedSteps), - ); - - return [ - 'mcp_connected' => data_get($steps, 'mcp'), - 'social_connected' => data_get($steps, 'social'), - 'first_post_created' => data_get($steps, 'first_post'), - 'skipped_steps' => $skippedSteps, - 'all_complete' => $allComplete, - 'show_progress' => $this->canShowProgress($user, $account) && ! $allComplete, - 'completed_at' => null, - 'dismissed_at' => $account->onboarding_dismissed_at?->toIso8601String(), - ]; - } - - /** - * @return array{mcp: bool, social: bool, first_post: bool} - */ - private function stepStates(Account $account): array - { - return [ - 'mcp' => $account->hasOnboardingMcpConnection(), - 'social' => $this->accountHasSocialConnection($account), - 'first_post' => $this->accountHasPost($account), - ]; - } - - /** - * Whether a checklist step is already done or skipped in a status payload. - * - * @param array{mcp_connected?: bool, social_connected?: bool, first_post_created?: bool, skipped_steps?: list} $status - */ - private function stepIsSettled(array $status, string $step): bool - { - return $this->stepIsComplete( - (bool) data_get($status, data_get(self::STEPS, $step)), - $step, - data_get($status, 'skipped_steps', []), - ); - } - - /** - * @param list $skippedSteps - */ - private function stepIsComplete(bool $done, string $step, array $skippedSteps): bool - { - return $done || in_array($step, $skippedSteps, true); - } - - /** - * @param list $skippedSteps - * @return list - */ - private function withoutSkippedStep(array $skippedSteps, string $step): array - { - return array_values(array_diff($skippedSteps, [$step])); - } - - /** - * @param array{skipped_steps: list, mcp_connected: bool, social_connected: bool, first_post_created: bool} $status - */ - private function completedStepCount(array $status): int - { - return collect(self::STEPS) - ->filter(fn (string $statusKey, string $step): bool => $this->stepIsComplete( - (bool) data_get($status, $statusKey), - $step, - data_get($status, 'skipped_steps', []), - )) - ->count(); - } - - /** - * @param array{mcp_connected: bool, social_connected: bool, first_post_created: bool} $status - */ - private function captureCompletedSteps(User $user, Account $account, array $status): void - { - foreach (self::STEPS as $step => $statusKey) { - if (! data_get($status, $statusKey)) { - continue; - } - - $this->postHog->capture( - $user->id, - OnboardingEvent::StepCompleted->value, - ['step' => $step], - $account, - ); - } - } - - private function accountHasSocialConnection(Account $account): bool - { - return Workspace::query() - ->where('account_id', $account->id) - ->whereHas( - 'socialAccounts', - fn (Builder $query): Builder => $query->where('status', Status::Connected), - ) - ->exists(); - } - - private function accountHasPost(Account $account): bool - { - return Workspace::query() - ->where('account_id', $account->id) - ->whereHas('posts') - ->exists(); - } - - private function clearMcpSkipIfConnected(Account $account): void - { - if (! $account->hasSkippedOnboardingStep('mcp') || ! $account->hasOnboardingMcpConnection()) { - return; - } - - Account::query() - ->whereKey($account->id) - ->whereNull('onboarding_completed_at') - ->update([ - 'onboarding_skipped_steps' => $this->withoutSkippedStep( - $account->skippedOnboardingSteps(), - 'mcp', - ) ?: null, - 'updated_at' => now(), - ]); - - $account->refresh(); - } -} diff --git a/app/Actions/SocialAccount/ConnectTelegramChannel.php b/app/Actions/SocialAccount/ConnectTelegramChannel.php index 4dc922f7e..282f76713 100644 --- a/app/Actions/SocialAccount/ConnectTelegramChannel.php +++ b/app/Actions/SocialAccount/ConnectTelegramChannel.php @@ -22,8 +22,8 @@ class ConnectTelegramChannel * Link a Telegram chat to a workspace for a one-off connect nonce. * * @param array $chat The `chat` object from the Bot API update. - * @return SocialAccount|null The linked account, or null when blocked (account - * limit reached or the code was already consumed). + * @return SocialAccount|null The linked account, or null when the code was + * already consumed or the chat did not match. */ public static function execute(Workspace $workspace, array $chat, string $nonce, mixed $reconnectId = null): ?SocialAccount { @@ -35,17 +35,6 @@ public static function execute(Workspace $workspace, array $chat, string $nonce, ->find($reconnectId) : null; - $isNewAccount = ! $workspace->socialAccounts() - ->where('platform', Platform::Telegram->value) - ->where('platform_user_id', $chatId) - ->exists(); - - if ($reconnect === null && $isNewAccount && SocialAccount::occupiesNetwork((string) $workspace->id, Platform::Telegram)) { - TelegramConnectFailed::dispatch($workspace->id, $nonce, 'network_taken'); - - return null; - } - // Reject before consuming the nonce so the user can retry in the right // chat with the code they already have. if ($reconnect !== null && (string) $reconnect->platform_user_id !== $chatId) { diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 597bdd6c9..2cae6e8b6 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -33,7 +33,7 @@ public static function execute(array $data, array $attributionParameters = []): ]; if (! $requiresCardForTrial) { - $accountAttributes['plan_id'] = Plan::where('slug', Slug::Workspace)->value('id'); + $accountAttributes['plan_id'] = Plan::where('slug', Slug::Socials)->value('id'); $accountAttributes['trial_ends_at'] = now()->addDays(config('cashier.trial_days')); } diff --git a/app/Actions/Workspace/CreateWorkspace.php b/app/Actions/Workspace/CreateWorkspace.php index 3c7c2df0e..96e8d08e0 100644 --- a/app/Actions/Workspace/CreateWorkspace.php +++ b/app/Actions/Workspace/CreateWorkspace.php @@ -42,8 +42,6 @@ public static function execute(User $user, array $data): Workspace return $workspace; }); - $user->account?->syncWorkspaceQuantity(); - return $workspace; } } diff --git a/app/Actions/Workspace/DeleteWorkspace.php b/app/Actions/Workspace/DeleteWorkspace.php index 1a7e9c27c..ad897b388 100644 --- a/app/Actions/Workspace/DeleteWorkspace.php +++ b/app/Actions/Workspace/DeleteWorkspace.php @@ -78,8 +78,6 @@ public static function execute(Workspace $workspace): bool $settlement->flush(); - $account?->syncWorkspaceQuantity(); - if (PostHogService::isEnabled()) { SyncAccountUsage::dispatch($accountId, null); } diff --git a/app/Enums/Billing/Interval.php b/app/Enums/Billing/Interval.php new file mode 100644 index 000000000..62b140bd4 --- /dev/null +++ b/app/Enums/Billing/Interval.php @@ -0,0 +1,21 @@ + $plan->stripe_monthly_price_id, + self::Yearly => $plan->stripe_yearly_price_id, + }; + } +} diff --git a/app/Enums/Plan/Slug.php b/app/Enums/Plan/Slug.php index b750b4bf9..c9e27c298 100644 --- a/app/Enums/Plan/Slug.php +++ b/app/Enums/Plan/Slug.php @@ -7,11 +7,15 @@ enum Slug: string { case Workspace = 'workspace'; + case Socials = 'socials'; + case Workspaces = 'workspaces'; public function label(): string { return match ($this) { self::Workspace => 'Workspace', + self::Socials => 'Socials', + self::Workspaces => 'Workspaces', }; } } diff --git a/app/Enums/PostHog/OnboardingEvent.php b/app/Enums/PostHog/OnboardingEvent.php deleted file mode 100644 index d09f96910..000000000 --- a/app/Enums/PostHog/OnboardingEvent.php +++ /dev/null @@ -1,13 +0,0 @@ - */ public function networkPlatformValues(): array @@ -455,12 +446,6 @@ public static function instagramConnectMethods(): array } /** - * Connectable platforms shaped for Inertia account/onboarding grids. - * Sorted alphabetically by label (ASC, case-insensitive). - * - * Instagram includes `connect_methods` so the connect dialog only lists - * OAuth entry points that are actually enabled (self-hosters may disable one). - * * @return list}> */ public static function connectableOptions(): array diff --git a/app/Events/OnboardingStatusUpdated.php b/app/Events/OnboardingStatusUpdated.php deleted file mode 100644 index 842339311..000000000 --- a/app/Events/OnboardingStatusUpdated.php +++ /dev/null @@ -1,103 +0,0 @@ -workspaces()->pluck('id') as $workspaceId) { - static::dispatch((string) $workspaceId); - } - } - - /** - * Sync progress then broadcast to every workspace on the account. - * Use when a step is account-scoped (e.g. MCP OAuth). - */ - public static function dispatchForAccount(Account $account, ?User $actor = null): void - { - if ($account->isOnboardingOpen()) { - static::syncAfterCommit($account->id, $actor?->id); - } - } - - /** - * Sync progress for the workspace account, then broadcast. - * Actor-less flows (webhooks/jobs) fall back to the account owner. - */ - public static function dispatchForWorkspace(?string $workspaceId, ?User $actor = null): void - { - $account = filled($workspaceId) - ? Workspace::query()->find($workspaceId)?->account - : null; - - if ($account?->isOnboardingOpen()) { - static::syncAfterCommit($account->id, $actor?->id); - } - } - - private static function syncAfterCommit(string $accountId, ?string $actorId): void - { - DB::afterCommit(function () use ($accountId, $actorId): void { - $account = Account::query()->find($accountId); - - if ($account?->isOnboardingOpen()) { - app(ResolveOnboardingStatus::class)->syncAndNotify($account, $actorId); - } - }); - } - - public function broadcastAs(): string - { - return 'onboarding.status.updated'; - } - - /** - * @return array - */ - public function broadcastOn(): array - { - return [ - new PrivateChannel("workspace.{$this->workspaceId}"), - ]; - } - - /** - * @return array{workspace_id: string} - */ - public function broadcastWith(): array - { - return [ - 'workspace_id' => $this->workspaceId, - ]; - } - - public function broadcastQueue(): string - { - return 'broadcasts'; - } -} diff --git a/app/Exceptions/SocialAccount/ConnectPopupException.php b/app/Exceptions/SocialAccount/ConnectPopupException.php index 8cd2f13bd..219e0aa83 100644 --- a/app/Exceptions/SocialAccount/ConnectPopupException.php +++ b/app/Exceptions/SocialAccount/ConnectPopupException.php @@ -37,7 +37,6 @@ public function render(Request $request): Response 'success' => false, 'message' => __("accounts.popup_callback.{$this->messageKey}"), 'platform' => $this->platform?->value, - 'onboardingProgress' => false, ]); } } diff --git a/app/Http/Controllers/App/BillingController.php b/app/Http/Controllers/App/BillingController.php index 5af24a76e..984d319ef 100644 --- a/app/Http/Controllers/App/BillingController.php +++ b/app/Http/Controllers/App/BillingController.php @@ -4,12 +4,18 @@ namespace App\Http\Controllers\App; +use App\Enums\Billing\Interval; +use App\Http\Requests\App\Billing\ChangePlanRequest; +use App\Http\Resources\App\PlanResource; use App\Models\Account; +use App\Models\Plan; +use App\Support\Billing\SubscriptionPlanSync; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; use Inertia\Inertia; use Inertia\Response; +use Laravel\Cashier\Exceptions\IncompletePayment; use Symfony\Component\HttpFoundation\Response as SymfonyResponse; class BillingController extends Controller @@ -25,16 +31,10 @@ public function processing(Request $request): Response|RedirectResponse return redirect()->route('app.calendar'); } - $user = $request->user(); - $account = $user->accountOrFail(); - - $subscriptionActive = $account->subscribed(Account::SUBSCRIPTION_NAME); - $redirectToOnboarding = $user->isAccountOwner() - && $account->isOnboardingOpen(); + $account = $request->user()->accountOrFail(); return Inertia::render('billing/Processing', [ - 'subscriptionActive' => $subscriptionActive, - 'redirectToOnboarding' => $redirectToOnboarding, + 'subscriptionActive' => $account->subscribed(Account::SUBSCRIPTION_NAME), ]); } @@ -58,7 +58,7 @@ public function index(Request $request): Response|RedirectResponse 'stripe_status', 'ends_at', ]), - 'plan' => $account->plan, + 'plan' => $account->plan ? PlanResource::make($account->plan)->resolve() : null, 'workspaceCount' => $account->workspaces()->count(), 'invoices' => $account->invoices()->map(fn ($invoice) => [ 'id' => $invoice->id, @@ -71,40 +71,51 @@ public function index(Request $request): Response|RedirectResponse ]); } - public function swapToYearly(Request $request): RedirectResponse + public function changePlan(ChangePlanRequest $request): RedirectResponse { if (config('trypost.self_hosted')) { return redirect()->route('app.calendar'); } $account = $request->user()->account; + $plan = Plan::active()->findOrFail($request->validated('plan_id')); + $interval = Interval::from($request->validated('interval')); - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); - abort_unless($account->subscribed(Account::SUBSCRIPTION_NAME), SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); + $authorization = Gate::inspect('swapPlan', [$account, $plan]); + + if ($authorization->denied()) { + return back()->with('flash.error', $authorization->message()); + } - $plan = $account->plan; - $yearlyPriceId = $plan?->stripe_yearly_price_id; + $priceId = $interval->priceIdFor($plan); - abort_if($yearlyPriceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No annual price configured'); + abort_if($priceId === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No price configured for this interval'); $subscription = $account->subscription(Account::SUBSCRIPTION_NAME); abort_if($subscription === null, SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'No active subscription'); - if ($subscription->stripe_price === $yearlyPriceId) { - return redirect()->route('app.billing.index'); + try { + if ($subscription->stripe_price !== $priceId) { + $subscription->swap($priceId); + } + } catch (IncompletePayment $exception) { + return redirect()->route('cashier.payment', [ + $exception->payment->id, + 'redirect' => route('app.billing.index'), + ]); } - $authorization = Gate::inspect('swapPlan', [$account]); + $subscription->refresh(); - if ($authorization->denied()) { - return back()->with('flash.error', $authorization->message()); + if (! SubscriptionPlanSync::allows($subscription->stripe_status)) { + return redirect()->route('app.billing.index'); } - $subscription->swap($yearlyPriceId); + $account->update(['plan_id' => $plan->id]); return redirect()->route('app.billing.index') - ->with('flash.success', __('billing.flash.switched_to_yearly')); + ->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name])); } public function portal(Request $request): RedirectResponse diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php deleted file mode 100644 index de66af804..000000000 --- a/app/Http/Controllers/App/OnboardingController.php +++ /dev/null @@ -1,95 +0,0 @@ -user(); - $workspace = $user->currentWorkspace; - $account = $user->accountOrFail(); - // Pure read on GET — observers + POST complete/skip stamp progress. - $status = $this->resolveOnboardingStatus->handle($user); - - if (data_get($status, 'dismissed_at') !== null) { - return redirect()->route('app.calendar'); - } - - if ( - $account->isOnboardingOpen() - && $user->isAccountOwner() - && ! data_get($status, 'all_complete') - ) { - $this->postHog->capture( - $user->id, - OnboardingEvent::Viewed->value, - account: $account, - ); - } - - return Inertia::render('onboarding/Index', [ - 'status' => fn (): array => $status, - 'canSkipSteps' => fn (): bool => $user->isAccountOwner(), - 'canManageAccounts' => fn (): bool => $user->can('manageAccounts', $workspace), - 'canCreatePost' => fn (): bool => $user->can('createPost', $workspace), - 'mcpUrl' => fn (): string => route('mcp.trypost'), - 'platforms' => fn (): array => SocialPlatform::connectableOptions(), - 'accounts' => fn (): array => SocialAccountResource::collection( - $workspace->socialAccounts()->orderBy('id')->get(), - )->resolve(), - ]); - } - - public function skipMcp(Request $request): RedirectResponse - { - abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); - - $this->resolveOnboardingStatus->skipMcp($request->user()); - - return back(); - } - - public function complete(Request $request): RedirectResponse - { - $user = $request->user(); - - abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN); - - $account = $user->accountOrFail(); - - if ($account->isOnboardingDismissed()) { - return redirect()->route('app.calendar'); - } - - if ($account->isOnboardingCompleted()) { - return redirect()->route('app.onboarding'); - } - - if (! data_get($this->resolveOnboardingStatus->handle($user), 'all_complete')) { - return redirect()->route('app.onboarding'); - } - - $this->resolveOnboardingStatus->markCompleted($user); - - return redirect()->route('app.onboarding'); - } -} diff --git a/app/Http/Controllers/App/Settings/UsageController.php b/app/Http/Controllers/App/Settings/UsageController.php deleted file mode 100644 index 7940ef58a..000000000 --- a/app/Http/Controllers/App/Settings/UsageController.php +++ /dev/null @@ -1,44 +0,0 @@ -route('app.calendar'); - } - - abort_unless($request->user()->isAccountOwner(), SymfonyResponse::HTTP_FORBIDDEN); - - $account = $request->user()->account; - - $totalSocialAccounts = 0; - $totalMembers = $account->users()->count(); - - foreach ($account->workspaces as $workspace) { - $totalSocialAccounts += $workspace->socialAccounts()->count(); - } - - return Inertia::render('settings/account/Usage', [ - 'usage' => [ - 'workspaceCount' => $account->workspaces->count(), - 'socialAccountCount' => $totalSocialAccounts, - 'memberCount' => $totalMembers, - 'creditsUsed' => BillingCycle::for($account)->usedCredits(), - 'monthlyCreditsLimit' => BillingCycle::for($account)->creditAllotment(), - ], - ]); - } -} diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php index d4267c7a8..d8dc8a08f 100644 --- a/app/Http/Controllers/App/WelcomeController.php +++ b/app/Http/Controllers/App/WelcomeController.php @@ -5,18 +5,22 @@ namespace App\Http\Controllers\App; use App\Actions\Billing\StartSubscriptionCheckout; -use App\Enums\Plan\Slug; +use App\Enums\Billing\Interval; use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\WelcomeEvent; use App\Enums\SocialAccount\Platform as SocialPlatform; +use App\Enums\SocialAccount\Status; use App\Enums\User\Goal; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Http\Requests\App\Welcome\StoreWelcomeConnectRequest; use App\Http\Requests\App\Welcome\StoreWelcomeGoalsRequest; use App\Http\Requests\App\Welcome\StoreWelcomePersonaRequest; +use App\Http\Requests\App\Welcome\StoreWelcomePlanRequest; use App\Http\Requests\App\Welcome\StoreWelcomeReferralSourceRequest; +use App\Http\Resources\App\PlanResource; use App\Http\Resources\App\SocialAccountResource; +use App\Http\Resources\App\WelcomeSummaryResource; use App\Models\Plan; use App\Services\PostHogService; use Illuminate\Http\RedirectResponse; @@ -34,9 +38,12 @@ public function persona(Request $request): InertiaResponse|RedirectResponse return $redirect; } + $user = $request->user(); + return Inertia::render('welcome/Persona', [ 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), - 'selected' => $request->user()->persona?->value, + 'selected' => $user->persona?->value, + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -75,6 +82,7 @@ public function goals(Request $request): InertiaResponse|RedirectResponse return Inertia::render('welcome/Goals', [ 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), 'selected' => $user->goals ?? [], + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -113,6 +121,7 @@ public function referralSource(Request $request): InertiaResponse|RedirectRespon return Inertia::render('welcome/ReferralSource', [ 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), 'selected' => $user->referral_source?->value, + 'welcome' => WelcomeSummaryResource::make($user), ]); } @@ -148,7 +157,8 @@ public function connect(Request $request): InertiaResponse|RedirectResponse return $redirect; } - $workspace = $request->user()->currentWorkspace; + $user = $request->user(); + $workspace = $user->currentWorkspace; abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); @@ -157,14 +167,12 @@ public function connect(Request $request): InertiaResponse|RedirectResponse 'accounts' => SocialAccountResource::collection( $workspace->socialAccounts()->orderBy('id')->get(), )->resolve(), + 'welcome' => WelcomeSummaryResource::make($user), ]); } - public function storeConnect( - StoreWelcomeConnectRequest $request, - StartSubscriptionCheckout $checkout, - PostHogService $postHog, - ): Response|RedirectResponse { + public function storeConnect(StoreWelcomeConnectRequest $request, PostHogService $postHog): RedirectResponse + { if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { return $redirect; } @@ -172,30 +180,57 @@ public function storeConnect( abort_unless($request->user()->currentWorkspace !== null, Response::HTTP_NOT_FOUND); $user = $request->user(); - $platforms = $request->connectedPlatforms(); - - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); - $priceId = $plan->stripe_monthly_price_id; - - abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Monthly price is not configured.'); - - $response = $checkout->redirect( - $user->account, - $priceId, - route('app.welcome.connect'), - ); try { $postHog->capture( $user->id, WelcomeEvent::Connect->value, - ['platforms' => $platforms], + ['platforms' => $request->connectedPlatforms()], $user->account, ); + } catch (Throwable $e) { + report($e); + } + + return redirect()->route('app.welcome.plan'); + } + + public function plan(Request $request): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true, requireConnect: true)) { + return $redirect; + } + + return Inertia::render('welcome/Plan', [ + 'plans' => PlanResource::collection( + Plan::active()->orderBy('sort')->get(), + )->resolve(), + 'welcome' => WelcomeSummaryResource::make($request->user()), + ]); + } + + public function storePlan( + StoreWelcomePlanRequest $request, + StartSubscriptionCheckout $checkout, + PostHogService $postHog, + ): Response|RedirectResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true, requireConnect: true)) { + return $redirect; + } + + $user = $request->user(); + $plan = Plan::active()->findOrFail($request->validated('plan_id')); + $priceId = Interval::Monthly->priceIdFor($plan); + + abort_if($priceId === null, Response::HTTP_INTERNAL_SERVER_ERROR, 'Price is not configured.'); + + $response = $checkout->redirect($user->account, $priceId, route('app.welcome.plan'), $plan); + + try { $postHog->capture( $user->id, CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => 'monthly'], + ['plan_name' => $plan->name, 'interval' => Interval::Monthly->value], $user->account, ); } catch (Throwable $e) { @@ -226,6 +261,7 @@ private function redirectIfStepIncomplete( Request $request, bool $requireGoals = false, bool $requireReferral = false, + bool $requireConnect = false, ): ?RedirectResponse { if ($redirect = $this->redirectIfUnavailable($request)) { return $redirect; @@ -245,6 +281,13 @@ private function redirectIfStepIncomplete( return redirect()->route('app.welcome.referral-source'); } + if ($requireConnect && ! $user->currentWorkspace?->socialAccounts() + ->where('status', Status::Connected) + ->exists() + ) { + return redirect()->route('app.welcome.connect'); + } + return null; } diff --git a/app/Http/Controllers/App/WorkspaceController.php b/app/Http/Controllers/App/WorkspaceController.php index 74db86f69..416b54c91 100644 --- a/app/Http/Controllers/App/WorkspaceController.php +++ b/app/Http/Controllers/App/WorkspaceController.php @@ -70,7 +70,9 @@ public function create(Request $request): Response|RedirectResponse { $this->authorize('create', Workspace::class); - if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($request->user())) { + $user = $request->user(); + + if ($redirect = $this->denyAdditionalWorkspace($user, redirectWhenAtLimit: false)) { return $redirect; } @@ -82,26 +84,25 @@ public function create(Request $request): Response|RedirectResponse ]); } - /** - * Block creating a paid additional workspace without an active subscription. - * Guards both the form (`create`) and the write (`store`) so a direct POST - * can't bootstrap a second billable workspace — which would also inflate the - * checkout quantity before the first subscription exists. - */ - private function denyAdditionalWorkspaceWithoutSubscription(User $user): ?RedirectResponse + private function denyAdditionalWorkspace(User $user, bool $redirectWhenAtLimit = true): ?RedirectResponse { - // An invited member joins exactly one account via the invite. Creating a - // workspace on their empty invite-signup shell would leave it non-empty - // and billable after accept abandons it — send them back to the invite. + // Invitee signup shells must stay empty until accept — otherwise they become billable. if (Invite::query()->where('email', $user->email)->whereNull('accepted_at')->exists()) { abort(403); } - if (! config('trypost.self_hosted') - && $user->ownedWorkspacesCount() > 0 - && ! $user->account?->hasActiveSubscription()) { + if (config('trypost.self_hosted') || $user->ownedWorkspacesCount() === 0) { + return null; + } + + if (! $user->account?->hasActiveSubscription()) { return redirect()->route('app.billing.index') - ->with('message', 'Subscribe to create more workspaces.'); + ->with('flash.error', __('workspaces.subscription_required')); + } + + if ($redirectWhenAtLimit && ! $user->account->canCreateWorkspace()) { + return redirect()->route('app.workspaces.create') + ->with('flash.error', __('workspaces.limit_reached')); } return null; @@ -122,7 +123,7 @@ public function store(StoreWorkspaceRequest $request, LogoAttacher $logoAttacher { $user = $request->user(); - if ($redirect = $this->denyAdditionalWorkspaceWithoutSubscription($user)) { + if ($redirect = $this->denyAdditionalWorkspace($user)) { return $redirect; } diff --git a/app/Http/Controllers/Auth/LinkedInController.php b/app/Http/Controllers/Auth/LinkedInController.php index 323c4cd79..d2ecdd941 100644 --- a/app/Http/Controllers/Auth/LinkedInController.php +++ b/app/Http/Controllers/Auth/LinkedInController.php @@ -144,7 +144,6 @@ public function selectIdentity(Request $request): InertiaResponse return Inertia::render('accounts/LinkedInSelect', [ 'person' => $connectable->first($isPerson), 'organizations' => $connectable->reject($isPerson)->values()->all(), - 'onboardingProgress' => false, ]); } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index 89fc62b3f..5dc880244 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -155,18 +155,9 @@ protected function reconnectAccount(Workspace $workspace, mixed $reconnectId = n ->find($reconnectId); } - /** - * Nothing on this network is left to connect: the card being reconnected is - * gone from the provider, this login has nothing left to offer, or the - * single slot is taken. - * - * A taken slot is a fact about our own rows, so it stands even when the provider - * listing came back short. The other two answers depend on having seen everything. - */ protected function noConnectableIdentities(?SocialAccount $reconnect, string $missingKey, bool $listingComplete = true): Response { $key = match (true) { - ! (bool) config('trypost.allow_multiple_social_accounts') && $reconnect === null => 'network_taken', $listingComplete => $reconnect !== null ? $missingKey : 'all_connected', default => 'pages_read_incomplete', }; @@ -175,13 +166,6 @@ protected function noConnectableIdentities(?SocialAccount $reconnect, string $mi } /** - * Narrow the identities a provider returned to the ones this card may take. - * - * A reconnect only ever offers its own identity. Otherwise every identity - * already connected on this network is dropped — including in multi-account - * mode, where the same identity could otherwise be connected twice under two - * platforms of one network (Instagram directly and via Facebook). - * * @param array> $identities * @return array> */ @@ -283,10 +267,6 @@ protected function forgetSocialConnectSession(): void * Render the Inertia page that notifies the opener and closes the connect * popup. Used by both the GET OAuth callbacks (a fresh popup page load) and * the XHR selection submits (an Inertia visit that swaps to this page). - * - * Always pass `onboardingProgress` as inline false so it overrides the shared - * deferred prop: after select the URL is still the select path, and a deferred - * reload would re-GET that route with a cleared session. */ protected function popupCallback(bool $success, string $message, ?string $platform = null): Response { @@ -296,7 +276,6 @@ protected function popupCallback(bool $success, string $message, ?string $platfo 'success' => $success, 'message' => $message, 'platform' => $platform, - 'onboardingProgress' => false, ]); } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index bd4864ba9..c3b19ae05 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,7 +4,6 @@ namespace App\Http\Middleware\App; -use App\Actions\Onboarding\ResolveOnboardingStatus; use App\Enums\Auth\SocialAuthProvider; use App\Enums\PostPlatform\ContentType; use App\Enums\User\Locale; @@ -12,10 +11,9 @@ use App\Http\Resources\App\HandleInertiaRequests\AuthPlanResource; use App\Http\Resources\App\HandleInertiaRequests\AuthUserResource; use App\Http\Resources\App\HandleInertiaRequests\AuthWorkspaceResource; -use App\Models\User; +use App\Http\Resources\App\PlanResource; +use App\Models\Plan; use Illuminate\Http\Request; -use Inertia\DeferProp; -use Inertia\Inertia; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -58,7 +56,6 @@ public function share(Request $request): array ], 'usage' => $account && ! $isSelfHosted ? $account->usage() : null, 'features' => $account && ! $isSelfHosted ? $account->featureLimits() : null, - 'onboardingProgress' => $this->onboardingProgress($request, $user), 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'flash' => $request->session()->get('flash', []), 'applicationUrl' => config('app.url'), @@ -67,7 +64,6 @@ public function share(Request $request): array 'languages' => Locale::options(), 'aiEnabled' => filled(config('ai.providers.'.config('ai.default').'.key')), 'selfHosted' => $isSelfHosted, - 'allowMultipleSocialAccounts' => (bool) config('trypost.allow_multiple_social_accounts'), 'googleAuthEnabled' => SocialAuthProvider::Google->isEnabled(), 'githubAuthEnabled' => SocialAuthProvider::GitHub->isEnabled(), ]; @@ -81,43 +77,15 @@ public function shareOnce(Request $request): array return [ ...parent::shareOnce($request), 'contentTypeMediaRules' => fn (): array => ContentType::mediaRulesForFrontend(), - ]; - } - - /** - * Defer step queries for mid-activation owners; everyone else gets false inline. - * - * Never defer on Passport consent *views*: Inertia deferred props re-request the - * same URL, Passport rotates `authToken` on every authorize hit, and approve then - * fails with InvalidAuthTokenException against the stale token still on the page. - * - * Social OAuth popup close pages set `onboardingProgress` to false in - * `SocialController::popupCallback()` so a deferred reload does not re-hit the - * select route after the connect session was cleared. - */ - private function onboardingProgress(Request $request, ?User $user): DeferProp|false - { - if ($this->isPassportConsentViewRequest($request)) { - return false; - } - - $onboarding = app(ResolveOnboardingStatus::class); + 'plans' => function (): array { + if (config('trypost.self_hosted') || auth()->user()?->account === null) { + return []; + } - return $user && $onboarding->canShowProgress($user) - ? Inertia::defer(fn (): array|false => $onboarding->sidebarProgress($user)) - : false; - } - - /** - * Exact GET consent-view route names only — not approve/deny, and not wildcards - * like passport.authorizations.* (those would suppress defer on POST approve too, - * which is unnecessary and easy to misread as "all OAuth"). - */ - private function isPassportConsentViewRequest(Request $request): bool - { - return $request->routeIs( - 'passport.authorizations.authorize', - 'passport.device.authorizations.authorize', - ); + return PlanResource::collection( + Plan::active()->orderBy('sort')->get() + )->resolve(); + }, + ]; } } diff --git a/app/Http/Requests/App/Billing/ChangePlanRequest.php b/app/Http/Requests/App/Billing/ChangePlanRequest.php new file mode 100644 index 000000000..ebb0d8bb6 --- /dev/null +++ b/app/Http/Requests/App/Billing/ChangePlanRequest.php @@ -0,0 +1,32 @@ +user()?->isAccountOwner(); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'plan_id' => [ + 'required', + 'uuid', + Rule::exists('plans', 'id')->where(fn ($query) => $query->where('is_archived', false)), + ], + 'interval' => ['required', Rule::enum(Interval::class)], + ]; + } +} diff --git a/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php b/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php new file mode 100644 index 000000000..c548f418e --- /dev/null +++ b/app/Http/Requests/App/Welcome/StoreWelcomePlanRequest.php @@ -0,0 +1,30 @@ + + */ + public function rules(): array + { + return [ + 'plan_id' => [ + 'required', + 'uuid', + Rule::exists('plans', 'id')->where(fn ($query) => $query->where('is_archived', false)), + ], + ]; + } +} diff --git a/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php b/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php index b500fe148..0e3f41061 100644 --- a/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php +++ b/app/Http/Resources/App/HandleInertiaRequests/AuthPlanResource.php @@ -4,6 +4,7 @@ namespace App\Http\Resources\App\HandleInertiaRequests; +use App\Enums\Billing\Interval; use App\Models\Account; use App\Models\Plan; @@ -16,14 +17,14 @@ public static function make(Account $account, Plan $plan): array { $subscription = $account->subscription(Account::SUBSCRIPTION_NAME); $interval = ($subscription && $subscription->stripe_price === $plan->stripe_yearly_price_id) - ? 'yearly' - : 'monthly'; + ? Interval::Yearly + : Interval::Monthly; return [ 'id' => $plan->id, 'slug' => $plan->slug->value, 'name' => $plan->name, - 'interval' => $interval, + 'interval' => $interval->value, ]; } } diff --git a/app/Http/Resources/App/PlanResource.php b/app/Http/Resources/App/PlanResource.php new file mode 100644 index 000000000..4bba06a31 --- /dev/null +++ b/app/Http/Resources/App/PlanResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'slug' => $this->slug->value, + 'name' => $this->name, + 'workspace_limit' => $this->workspace_limit, + ]; + } +} diff --git a/app/Http/Resources/App/WelcomeSummaryResource.php b/app/Http/Resources/App/WelcomeSummaryResource.php new file mode 100644 index 000000000..f403fc075 --- /dev/null +++ b/app/Http/Resources/App/WelcomeSummaryResource.php @@ -0,0 +1,74 @@ +, + * networks: list + * } + */ + public static function make(User $user): array + { + /** @var Persona|null $persona */ + $persona = $user->persona ?? null; + + return [ + 'persona' => $persona?->value, + 'goals' => self::currentGoals($user->goals ?? null), + 'networks' => self::connectedNetworks($user), + ]; + } + + /** + * @param list|null $goals + * @return list + */ + private static function currentGoals(?array $goals): array + { + if (! is_array($goals)) { + return []; + } + + $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); + + return array_values(array_intersect($goals, $allowed)); + } + + /** + * @return list + */ + private static function connectedNetworks(User $user): array + { + $workspace = $user->currentWorkspace; + + if ($workspace === null) { + return []; + } + + return $workspace->socialAccounts() + ->where('status', Status::Connected) + ->orderBy('id') + ->get() + ->map(fn (SocialAccount $account): array => [ + 'id' => $account->id, + 'platform' => $account->platform->value, + 'display_label' => $account->display_label, + 'username' => $account->username, + 'avatar_url' => $account->avatar_url, + ]) + ->values() + ->all(); + } +} diff --git a/app/Jobs/VerifyUpcomingPostConnections.php b/app/Jobs/VerifyUpcomingPostConnections.php index 311a75031..934ca96e0 100644 --- a/app/Jobs/VerifyUpcomingPostConnections.php +++ b/app/Jobs/VerifyUpcomingPostConnections.php @@ -333,10 +333,9 @@ private function atRiskPostPlatforms(): Collection ->whereBetween('scheduled_at', [now(), now()->addHour()]); }) // socialAccount.workspace is eager-loaded even though this job - // never reads it directly — SocialAccountObserver::notifyOnboarding() + // never reads it directly — SocialAccountObserver::syncUsage() // (fired by the ->update() calls below via markAsTokenExpired()) - // reads $account->workspace. The observer self-heals with - // loadMissing() (see #255), but without this eager load every + // reads $account->workspace. Without this eager load every // account in the batch triggers its own extra query there. ->with(['socialAccount.workspace', 'post']) ->get(); diff --git a/app/Listeners/StripeEventListener.php b/app/Listeners/StripeEventListener.php index ba734a397..5c0af45da 100644 --- a/app/Listeners/StripeEventListener.php +++ b/app/Listeners/StripeEventListener.php @@ -12,6 +12,7 @@ use App\Models\Account; use App\Models\Plan; use App\Services\PostHogService; +use App\Support\Billing\SubscriptionPlanSync; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; use Laravel\Cashier\Events\WebhookReceived; @@ -59,12 +60,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload): { $previousPlan = $account->plan?->name; - if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { - $account->update([ - 'plan_id' => $plan->id, - 'trial_ends_at' => null, - ]); - } + $this->syncPlanFromSubscription($account, $payload, clearGenericTrial: true); $this->trackPlanChange($account, BillingEvent::Created, $previousPlan, $payload); $this->trackSubscriptionStart($account, $payload); @@ -77,9 +73,7 @@ protected function handleSubscriptionUpdated(Account $account, array $payload): { $previousPlan = $account->plan?->name; - if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) { - $account->update(['plan_id' => $plan->id]); - } + $this->syncPlanFromSubscription($account, $payload); $this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload); $this->trackTrialConversion($account, $payload); @@ -117,6 +111,36 @@ private function alreadyProcessed(?string $eventId): bool return ! Cache::add("stripe_webhook_event:{$eventId}", true, now()->addDay()); } + /** + * @param array $payload + */ + private function syncPlanFromSubscription(Account $account, array $payload, bool $clearGenericTrial = false): void + { + $status = $this->currentStatus($payload); + + if (SubscriptionPlanSync::allows($status)) { + $plan = $this->resolvePlanFromSubscriptionItems($payload, $account); + + if ($plan === null) { + return; + } + + $attributes = ['plan_id' => $plan->id]; + + if ($clearGenericTrial) { + $attributes['trial_ends_at'] = null; + } + + $account->update($attributes); + + return; + } + + if (SubscriptionPlanSync::clears($status) && $account->plan_id !== null) { + $account->update(['plan_id' => null]); + } + } + /** * @param array $payload */ diff --git a/app/Models/AccessToken.php b/app/Models/AccessToken.php index d202d1896..d686fedd6 100644 --- a/app/Models/AccessToken.php +++ b/app/Models/AccessToken.php @@ -4,13 +4,10 @@ namespace App\Models; -use App\Observers\AccessTokenObserver; -use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Passport\Token; -#[ObservedBy(AccessTokenObserver::class)] class AccessToken extends Token { /** @@ -179,22 +176,6 @@ public function isActiveMcpGrant(): bool return $this->expires_at === null || ! $this->expires_at->isPast(); } - /** - * Bound MCP grant that unlocks the account onboarding checklist - * (account-owner grant + usable + createPost on the bound workspace). - */ - public function unlocksOnboardingChecklist(?User $user = null): bool - { - $user ??= $this->user; - $workspace = $this->workspace; - - return $user instanceof User - && $workspace instanceof Workspace - && $user->isAccountOwner() - && $this->isUsableMcpGrant($user, $workspace) - && $user->can('createPost', $workspace); - } - /** * Whether a refresh token can still mint a new access token for this grant. */ diff --git a/app/Models/Account.php b/app/Models/Account.php index f082e6e39..46090bd53 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -4,7 +4,6 @@ namespace App\Models; -use App\Models\Traits\HasOnboarding; use App\Models\Traits\HasUsage; use Carbon\CarbonInterface; use Database\Factories\AccountFactory; @@ -13,21 +12,15 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Support\Facades\Log; use Laravel\Cashier\Billable; -use Throwable; class Account extends Model { /** @use HasFactory */ - use Billable, HasFactory, HasOnboarding, HasUsage, HasUuids; + use Billable, HasFactory, HasUsage, HasUuids; public const SUBSCRIPTION_NAME = 'default'; - /** - * Redis/cache key for aggregated post counts across the account's workspaces. - * Invalidated by the PostHog usage sync job before re-reading aggregates for analytics. - */ public static function postsCountCacheKey(string $accountId): string { return "account:{$accountId}:posts_count"; @@ -82,10 +75,6 @@ public function hasActiveSubscription(): bool return $this->subscribed(self::SUBSCRIPTION_NAME); } - /** - * Whether the account may use the app (active subscription, or a generic - * trial when REQUIRE_CARD_FOR_TRIAL is disabled). - */ public function hasAppAccess(): bool { if (config('trypost.self_hosted')) { @@ -98,31 +87,32 @@ public function hasAppAccess(): bool || (! $requiresCardForTrial && $this->isOnTrial()); } - /** - * Align the Stripe subscription quantity with the number of workspaces the - * account owns. Each workspace is a billed unit. No-op in self-hosted mode - * or when there is no active subscription (e.g. during onboarding). - */ - public function syncWorkspaceQuantity(): void + public function workspaceLimit(): ?int { if (config('trypost.self_hosted')) { - return; + return null; } - $subscription = $this->subscription(self::SUBSCRIPTION_NAME); + if ($this->plan === null) { + return 1; + } - if (! $subscription || ! $subscription->active()) { - return; + return $this->plan->workspace_limit; + } + + public function canCreateWorkspace(): bool + { + if (config('trypost.self_hosted')) { + return true; } - try { - $subscription->updateQuantity($this->workspaces()->count()); - } catch (Throwable $e) { - Log::warning('Failed to sync workspace quantity to Stripe', [ - 'account_id' => $this->id, - 'error' => $e->getMessage(), - ]); + $limit = $this->workspaceLimit(); + + if ($limit === null) { + return true; } + + return $this->workspaces()->count() < $limit; } public function isPastDue(): bool diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 74cc1226a..21904005b 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -22,7 +22,7 @@ class Plan extends Model 'name', 'stripe_monthly_price_id', 'stripe_yearly_price_id', - 'monthly_credits_limit', + 'workspace_limit', 'sort', 'is_archived', ]; @@ -32,7 +32,7 @@ protected function casts(): array return [ 'slug' => Slug::class, 'is_archived' => 'boolean', - 'monthly_credits_limit' => 'integer', + 'workspace_limit' => 'integer', 'sort' => 'integer', ]; } diff --git a/app/Models/SocialAccount.php b/app/Models/SocialAccount.php index f50a2d5a2..d9a4697bf 100644 --- a/app/Models/SocialAccount.php +++ b/app/Models/SocialAccount.php @@ -88,15 +88,6 @@ public function workspace(): BelongsTo return $this->belongsTo(Workspace::class); } - public static function occupiesNetwork(string $workspaceId, SocialPlatform $platform): bool - { - return ! config('trypost.allow_multiple_social_accounts') - && static::query() - ->where('workspace_id', $workspaceId) - ->whereIn('platform', $platform->networkPlatformValues()) - ->exists(); - } - /** * Persist a freshly authorized identity. * @@ -113,9 +104,8 @@ public static function connectIdentity( array $values, ?self $reconnect = null, ): self { - // The one-per-network rule is a config flag, so no database constraint - // can hold it and the observer's check-then-insert would let two popups - // finishing at once seat two different identities on one network. + // Two popups finishing at once for the same network must not interleave + // a reconnect's update-and-realign transaction with a fresh insert. try { return Cache::lock("social_connect:{$workspace->id}:{$platform->network()}", 10) ->block(5, fn (): self => static::persistIdentity( diff --git a/app/Models/Traits/HasAccount.php b/app/Models/Traits/HasAccount.php index 18ff131be..291d3dbff 100644 --- a/app/Models/Traits/HasAccount.php +++ b/app/Models/Traits/HasAccount.php @@ -14,10 +14,6 @@ public function account(): BelongsTo return $this->belongsTo(Account::class); } - /** - * Account for authenticated product flows (onboarding, billing, settings). - * In-app users always have one; null only during account teardown. - */ public function accountOrFail(): Account { return $this->account ?? $this->account()->firstOrFail(); diff --git a/app/Models/Traits/HasOnboarding.php b/app/Models/Traits/HasOnboarding.php deleted file mode 100644 index cc75c42c9..000000000 --- a/app/Models/Traits/HasOnboarding.php +++ /dev/null @@ -1,92 +0,0 @@ -isOnboardingCompleted() || $this->isOnboardingDismissed(); - } - - public function isOnboardingCompleted(): bool - { - return $this->onboarding_completed_at !== null; - } - - public function isOnboardingDismissed(): bool - { - return $this->onboarding_dismissed_at !== null; - } - - /** - * Whether activation progress should still be tracked for this account. - * Prefer `$account?->isOnboardingOpen()` at call sites so null accounts bail out cleanly. - */ - public function isOnboardingOpen(): bool - { - return ! $this->hasFinishedOnboarding(); - } - - /** - * @return list - */ - public function skippedOnboardingSteps(): array - { - return $this->onboarding_skipped_steps ?? []; - } - - public function hasSkippedOnboardingStep(string $step): bool - { - return in_array($step, $this->skippedOnboardingSteps(), true); - } - - /** - * Whether the account owner has a bound MCP OAuth grant that unlocks - * the checklist. Teammate grants do not count — activation is owner-owned. - * Early-exits on the first matching grant so accounts with many MCP - * tokens do not pay for a full collection + Gate pass every time. - */ - public function hasOnboardingMcpConnection(): bool - { - if ($this->owner_id === null) { - return false; - } - - $tokens = AccessToken::query() - ->activeMcpOAuth() - ->where('user_id', $this->owner_id) - ->whereNotNull('workspace_id') - ->whereHas( - 'workspace', - fn (Builder $workspace): Builder => $workspace->where('account_id', $this->id), - ) - ->with(['user', 'workspace', 'client']) - ->lazyById(); - - foreach ($tokens as $token) { - if ($token->unlocksOnboardingChecklist()) { - return true; - } - } - - return false; - } - - /** - * @param Builder $query - * @return Builder - */ - public function scopeOnboardingOpen(Builder $query): Builder - { - return $query - ->whereNull('onboarding_completed_at') - ->whereNull('onboarding_dismissed_at'); - } -} diff --git a/app/Models/Traits/HasUsage.php b/app/Models/Traits/HasUsage.php index f37b2ff23..7e57c2a11 100644 --- a/app/Models/Traits/HasUsage.php +++ b/app/Models/Traits/HasUsage.php @@ -10,21 +10,8 @@ use App\Support\BillingCycle; use Illuminate\Support\Facades\Cache; -/** - * Provides account-level usage counts and plan-resolved feature limits. - * - * `featureLimits()` resolves the account's per-cycle credit allotment directly - * from BillingCycle, computed fresh from the plan, workspace count, and billing - * interval — no caching, so there is nothing to invalidate. - */ trait HasUsage { - /** - * Cache TTL for the per-account post count. Posts are unbounded by plan - * limits and not used for any quota gating, so a few minutes of staleness - * is acceptable in exchange for skipping a potentially heavy aggregate - * query on every authenticated request. - */ private const POST_COUNT_CACHE_TTL = 300; /** @@ -49,13 +36,11 @@ public function usage(): array } /** - * @return array{monthlyCreditsLimit: int} + * @return array{workspaceLimit: int|null} */ public function featureLimits(): array { - return [ - 'monthlyCreditsLimit' => BillingCycle::for($this)->creditAllotment(), - ]; + return ['workspaceLimit' => $this->workspaceLimit()]; } /** diff --git a/app/Observers/AccessTokenObserver.php b/app/Observers/AccessTokenObserver.php deleted file mode 100644 index c1acc83f2..000000000 --- a/app/Observers/AccessTokenObserver.php +++ /dev/null @@ -1,56 +0,0 @@ -broadcastIfMcpOAuth($accessToken); - } - - public function updated(AccessToken $accessToken): void - { - if ($accessToken->wasChanged('revoked')) { - $this->broadcastIfMcpOAuth($accessToken); - } - } - - private function broadcastIfMcpOAuth(AccessToken $accessToken): void - { - if (! $this->looksLikeMcpOAuth($accessToken)) { - return; - } - - $user = User::query() - ->with(['account', 'currentWorkspace']) - ->find($accessToken->user_id); - - if ($user === null) { - return; - } - - // Revocation always notifies so progress can clear. Live grants only - // unlock when bound + createPost (viewers must not complete the checklist). - if ($accessToken->revoked || $accessToken->unlocksOnboardingChecklist($user)) { - OnboardingStatusUpdated::dispatchForAccount($user->accountOrFail(), $user); - } - } - - private function looksLikeMcpOAuth(AccessToken $accessToken): bool - { - // Ignore personal-access tokens; treat mid-revoke rows as MCP so - // disconnect still clears progress. - return in_array('mcp:use', $accessToken->scopes ?? [], true) - && ! $accessToken->isPersonalAccessToken(); - } -} diff --git a/app/Observers/PostObserver.php b/app/Observers/PostObserver.php index bfe0b34fd..49f93a539 100644 --- a/app/Observers/PostObserver.php +++ b/app/Observers/PostObserver.php @@ -5,12 +5,9 @@ namespace App\Observers; use App\Enums\Post\Status as PostStatus; -use App\Events\OnboardingStatusUpdated; use App\Events\PostCreated; use App\Events\PostStatusChanged; -use App\Models\Account; use App\Models\Post; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; class PostObserver @@ -18,13 +15,6 @@ class PostObserver public function created(Post $post): void { DB::afterCommit(fn () => PostCreated::dispatch($post)); - - $this->notifyOnboarding($post); - } - - public function deleted(Post $post): void - { - $this->notifyOnboarding($post); } public function saved(Post $post): void @@ -52,27 +42,4 @@ private function previousStatus(Post $post): ?PostStatus return null; } - - /** - * Notify only on the account's first post create or last post delete — - * later creates / partial deletes would spam Echo while activation is open. - */ - private function notifyOnboarding(Post $post): void - { - $account = $post->workspace?->account; - - if ($account?->isOnboardingOpen() && $this->otherPosts($account, $post)->doesntExist()) { - OnboardingStatusUpdated::dispatchForWorkspace($post->workspace_id, $post->user); - } - } - - /** - * @return Builder - */ - private function otherPosts(Account $account, Post $post): Builder - { - return Post::query() - ->whereIn('workspace_id', $account->workspaces()->select('id')) - ->whereKeyNot($post->id); - } } diff --git a/app/Observers/SocialAccountObserver.php b/app/Observers/SocialAccountObserver.php index df790ecfe..e7bf28cbf 100644 --- a/app/Observers/SocialAccountObserver.php +++ b/app/Observers/SocialAccountObserver.php @@ -4,10 +4,7 @@ namespace App\Observers; -use App\Enums\SocialAccount\Platform as SocialPlatform; use App\Enums\SocialAccount\Status; -use App\Events\OnboardingStatusUpdated; -use App\Exceptions\SocialAccount\NetworkAlreadyConnectedException; use App\Jobs\PostHog\IdentifyConnectedPlatforms; use App\Jobs\PostHog\SyncAccountUsage; use App\Models\SocialAccount; @@ -16,32 +13,14 @@ class SocialAccountObserver { - /** - * Enforce one connected account per social network per workspace. Variants - * of the same network (LinkedIn profile/page, Instagram standalone/Facebook) - * collapse via Platform::network(). Reconnecting an existing account updates - * the row and never reaches this hook. Bypassed when - * trypost.allow_multiple_social_accounts is true. - */ - public function creating(SocialAccount $socialAccount): void - { - if (! $socialAccount->platform instanceof SocialPlatform) { - return; - } - - if (SocialAccount::occupiesNetwork((string) $socialAccount->workspace_id, $socialAccount->platform)) { - throw new NetworkAlreadyConnectedException($socialAccount->platform); - } - } - public function created(SocialAccount $socialAccount): void { - $this->syncUsageAndOnboarding($socialAccount); + $this->syncUsageAndIdentify($socialAccount); } public function deleted(SocialAccount $socialAccount): void { - $this->syncUsageAndOnboarding($socialAccount); + $this->syncUsageAndIdentify($socialAccount); } public function deleting(SocialAccount $socialAccount): void @@ -62,18 +41,13 @@ public function updated(SocialAccount $socialAccount): void if ($wasConnected !== $isConnected) { $this->identifyConnectedPlatforms($socialAccount); - $this->notifyOnboarding($socialAccount); } } - private function syncUsageAndOnboarding(SocialAccount $socialAccount): void + private function syncUsageAndIdentify(SocialAccount $socialAccount): void { $this->syncUsage($socialAccount); $this->identifyConnectedPlatforms($socialAccount); - - if ($socialAccount->status === Status::Connected) { - $this->notifyOnboarding($socialAccount); - } } private function identifyConnectedPlatforms(SocialAccount $socialAccount): void @@ -85,31 +59,6 @@ private function identifyConnectedPlatforms(SocialAccount $socialAccount): void IdentifyConnectedPlatforms::dispatch((string) $socialAccount->workspace_id); } - /** - * First usable connect / last disconnect for the account. - * Actor-less → syncAndNotify falls back to the account owner. - */ - private function notifyOnboarding(SocialAccount $socialAccount): void - { - $socialAccount->loadMissing('workspace.account'); - - $account = $socialAccount->workspace?->account; - - if (! $account?->isOnboardingOpen()) { - return; - } - - $hasOtherConnections = SocialAccount::query() - ->whereIn('workspace_id', $account->workspaces()->select('id')) - ->whereKeyNot($socialAccount->id) - ->where('status', Status::Connected) - ->exists(); - - if (! $hasOtherConnections) { - OnboardingStatusUpdated::dispatchForWorkspace($socialAccount->workspace_id); - } - } - private function syncUsage(SocialAccount $socialAccount): void { if (PostHogService::isEnabled()) { diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index 48be159f0..94ba8edfc 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -5,8 +5,8 @@ namespace App\Policies; use App\Models\Account; +use App\Models\Plan; use App\Models\User; -use App\Support\BillingCycle; use Illuminate\Auth\Access\Response; class AccountPolicy @@ -21,11 +21,6 @@ public function manageBilling(User $user, Account $account): bool return $user->id === $account->owner_id; } - /** - * Authorize using AI features. Requires an active subscription (or trial) - * and remaining monthly credits. Manual post creation is unaffected — only - * AI calls are gated by this check. - */ public function useAi(User $user, Account $account): Response { if (config('trypost.self_hosted')) { @@ -36,29 +31,29 @@ public function useAi(User $user, Account $account): Response return Response::deny(__('billing.flash.subscription_required')); } - $cycle = BillingCycle::for($account); - $limit = $cycle->creditAllotment(); - - if ($cycle->usedCredits() >= $limit) { - return Response::deny(__('billing.flash.credits_exhausted', [ - 'limit' => (string) $limit, - ])); - } - return Response::allow(); } - /** - * Authorize swapping the account's subscription billing interval. Only the - * account owner may change billing; per-workspace pricing has no plan tiers - * to downgrade between, so there are no usage-based restrictions. - */ - public function swapPlan(User $user, Account $account): Response + public function swapPlan(User $user, Account $account, Plan $target): Response { if ($user->id !== $account->owner_id) { return Response::deny(__('billing.flash.cannot_manage')); } + if (! $account->subscribed(Account::SUBSCRIPTION_NAME)) { + return Response::deny(__('billing.flash.subscription_required')); + } + + $limit = $target->workspace_limit; + $count = $account->workspaces()->count(); + + if ($limit !== null && $count > $limit) { + return Response::deny(__('billing.flash.too_many_workspaces', [ + 'count' => $count, + 'limit' => $limit, + ])); + } + return Response::allow(); } } diff --git a/app/Policies/WorkspacePolicy.php b/app/Policies/WorkspacePolicy.php index 107871649..b3daafa93 100644 --- a/app/Policies/WorkspacePolicy.php +++ b/app/Policies/WorkspacePolicy.php @@ -32,7 +32,6 @@ public function update(User $user, Workspace $workspace): bool public function delete(User $user, Workspace $workspace): bool { - // Owner-only: deleting a workspace changes Stripe subscription quantity. return $this->isOwner($user, $workspace); } diff --git a/app/Support/Billing/ConfigureSubscriptionCheckout.php b/app/Support/Billing/ConfigureSubscriptionCheckout.php index bcec132c4..d810341ea 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\Plan; use Laravel\Cashier\SubscriptionBuilder; use RuntimeException; use Stripe\Subscription as StripeSubscription; @@ -17,30 +18,21 @@ final class ConfigureSubscriptionCheckout public const MIN_CHECKOUT_TRIAL_DAYS = 2; /** - * Apply env-driven checkout options to a subscription builder. - * - * Precedence when REQUIRE_CARD_FOR_TRIAL is enabled: - * 1. Qualifying first-month coupon → withCoupon, no trialDays (card charge now). - * 2. Else first-time customer + CASHIER_TRIAL_DAYS > 0 → trialDays (clamped to ≥ 2). - * 3. Else plain checkout (immediate full price) — including re-subscribers. - * - * CASHIER_ALLOW_PROMOTION_CODES enables the Checkout promo field only when no - * coupon is applied — Stripe rejects discounts + allow_promotion_codes together. - * - * @throws RuntimeException when a coupon would be applied while - * allow_promotion_codes is also enabled. + * @throws RuntimeException when a coupon would apply with allow_promotion_codes enabled */ - public static function apply(SubscriptionBuilder $subscription, Account $account): SubscriptionBuilder + public static function apply(SubscriptionBuilder $subscription, Account $account, ?Plan $plan = null): SubscriptionBuilder { - if (self::shouldApplyFirstMonthCoupon($account)) { + $couponId = self::firstMonthCouponId($account, $plan); + + if ($couponId !== null) { if ((bool) config('cashier.allow_promotion_codes', false)) { throw new RuntimeException( - 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled: ' + '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((string) config('cashier.first_month_coupon_id')); + return $subscription->withCoupon($couponId); } if ( @@ -61,33 +53,26 @@ public static function apply(SubscriptionBuilder $subscription, Account $account return $subscription; } - /** - * Fixed amount_off first-month coupons only fit a new customer checking out a - * single workspace. A subscription that never left incomplete never became - * real, so a retry after a failed first attempt still qualifies; any started - * subscription (even canceled) does not. - */ - private static function shouldApplyFirstMonthCoupon(Account $account): bool + private static function firstMonthCouponId(Account $account, ?Plan $plan): ?string { - if (! (bool) config('trypost.billing.require_card_for_trial', true)) { - return false; + if ($plan === null || ! (bool) config('trypost.billing.require_card_for_trial', true)) { + return null; } - $couponId = config('cashier.first_month_coupon_id'); + if (! self::isFirstTimeSubscriber($account)) { + return null; + } + + $couponId = config("cashier.first_month_coupon_ids.{$plan->slug->value}"); if (! is_string($couponId) || $couponId === '') { - return false; + return null; } - return $account->workspaces()->count() === 1 - && self::isFirstTimeSubscriber($account); + return $couponId; } - /** - * True when the account has never had a real Stripe subscription. Rows that - * stayed in incomplete / incomplete_expired never became billable, so a - * retry after a failed first Checkout still counts as first-time. - */ + /** Incomplete / incomplete_expired never became billable, so retries still count as first-time. */ private static function isFirstTimeSubscriber(Account $account): bool { return ! $account->subscriptions() diff --git a/app/Support/Billing/SubscriptionPlanSync.php b/app/Support/Billing/SubscriptionPlanSync.php new file mode 100644 index 000000000..cb55ab5cb --- /dev/null +++ b/app/Support/Billing/SubscriptionPlanSync.php @@ -0,0 +1,32 @@ +subscription(); - $plan = $this->account->plan; - - if ($subscription !== null - && $plan?->stripe_yearly_price_id !== null - && $subscription->stripe_price === $plan->stripe_yearly_price_id - ) { - return 12; - } - - return 1; - } - - public function creditAllotment(): int - { - $base = (int) ($this->account->plan?->monthly_credits_limit ?? 0); - $months = $this->onTrial() ? 1 : $this->intervalMonths(); - - return $base * $this->account->workspaces()->count() * $months; - } - public function usedCredits(): int { return AiUsageLog::creditsUsedBetween( @@ -98,7 +68,7 @@ private function computeWindow(): array $anchor = $this->anchor(); $now = CarbonImmutable::now(); - $step = $this->intervalMonths(); + $step = $this->isYearly() ? 12 : 1; $periods = 0; @@ -112,6 +82,16 @@ private function computeWindow(): array ]; } + private function isYearly(): bool + { + $subscription = $this->subscription(); + $plan = $this->account->plan; + + return $subscription !== null + && $plan?->stripe_yearly_price_id !== null + && $subscription->stripe_price === $plan->stripe_yearly_price_id; + } + private function anchor(): CarbonImmutable { $subscription = $this->subscription(); @@ -124,11 +104,6 @@ private function anchor(): CarbonImmutable return CarbonImmutable::parse($anchor); } - private function onTrial(): bool - { - return (bool) $this->subscription()?->onTrial(); - } - private function subscription(): ?Subscription { if (! $this->subscriptionResolved) { diff --git a/app/Support/StripeSubscriptionConversion.php b/app/Support/StripeSubscriptionConversion.php index 7e289c99b..30cdf73fd 100644 --- a/app/Support/StripeSubscriptionConversion.php +++ b/app/Support/StripeSubscriptionConversion.php @@ -13,7 +13,7 @@ final class StripeSubscriptionConversion * subscription webhook payload — trial.started, checkout.completed, and * trial.converted all start from this shape. Persona is not included * here: it is already set as a person property via identify() during - * onboarding, so it is joinable on every event without repeating it. + * welcome, so it is joinable on every event without repeating it. * * @param array $payload * @return array diff --git a/compose.prod.yaml b/compose.prod.yaml index 929d5cb53..f15bc57d2 100644 --- a/compose.prod.yaml +++ b/compose.prod.yaml @@ -21,7 +21,6 @@ services: APP_KEY: "" # <- run key:generate (see header) and paste here APP_URL: http://localhost:8000 # <- your public URL, e.g. https://post.yourdomain.com SELF_HOSTED: "true" - ALLOW_MULTIPLE_SOCIAL_ACCOUNTS: "true" TRYPOST_TARGET: production # ===== Database (bundled postgres service below) ===== diff --git a/config/cashier.php b/config/cashier.php index 8b4ed78d1..e08fc3b2b 100644 --- a/config/cashier.php +++ b/config/cashier.php @@ -144,18 +144,24 @@ /* |-------------------------------------------------------------------------- - | Paid First Month Coupon + | Paid First Month Coupons |-------------------------------------------------------------------------- | - | Optional Stripe Coupon ID (amount_off, duration=once). When set and the - | account qualifies (card required, single workspace, first-time), checkout - | applies withCoupon and skips trialDays so the first invoice validates the - | card. Empty = no coupon; card-required checkouts use trial_days instead. - | Cannot be combined with allow_promotion_codes on the same checkout. + | 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_id' => env('STRIPE_FIRST_MONTH_COUPON_ID'), + 'first_month_coupon_ids' => [ + 'socials' => env('STRIPE_SOCIALS_FIRST_MONTH_COUPON_ID'), + 'workspaces' => env('STRIPE_WORKSPACES_FIRST_MONTH_COUPON_ID'), + ], /* |-------------------------------------------------------------------------- diff --git a/config/trypost.php b/config/trypost.php index 90b50ee01..0fdd5ee77 100644 --- a/config/trypost.php +++ b/config/trypost.php @@ -10,7 +10,7 @@ |-------------------------------------------------------------------------- | | When enabled, the application runs in self-hosted mode which skips - | payment/subscription requirements during onboarding. + | payment/subscription requirements during welcome. | */ @@ -45,26 +45,6 @@ 'meta_page_walk_seconds' => (int) env('META_PAGE_WALK_SECONDS', 20), - /* - |-------------------------------------------------------------------------- - | Multiple social accounts per network - |-------------------------------------------------------------------------- - | - | When false (Cloud default), a workspace may connect only one account - | per social network. Variants of the same network (LinkedIn profile/page, - | Instagram standalone/Facebook) count as one. Reconnecting the same - | identity (platform + platform_user_id) still updates the existing row. - | - | Independent of SELF_HOSTED so Cloud can flip this later without becoming - | self-hosted. Self-hosted installs typically set this true. - | - */ - - 'allow_multiple_social_accounts' => (bool) env( - 'ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', - env('SELF_HOSTED', true), - ), - /* |-------------------------------------------------------------------------- | Security diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index fd3d562fe..e8ed8f87e 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -21,16 +21,23 @@ class PlanFactory extends Factory public function definition(): array { return [ - 'slug' => Slug::Workspace, - 'name' => 'Workspace', + 'slug' => Slug::Socials, + 'name' => 'Socials', 'stripe_monthly_price_id' => null, 'stripe_yearly_price_id' => null, - 'monthly_credits_limit' => 2500, + 'workspace_limit' => 1, 'sort' => 0, 'is_archived' => false, ]; } + public function unlimited(): static + { + return $this->state(fn (array $attributes): array => [ + 'workspace_limit' => null, + ]); + } + public function archived(): static { return $this->state(fn (array $attributes): array => [ diff --git a/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php b/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php new file mode 100644 index 000000000..ef6e8f81a --- /dev/null +++ b/database/migrations/2026_09_08_173316_add_workspace_limit_to_plans_table.php @@ -0,0 +1,24 @@ +unsignedInteger('workspace_limit')->nullable()->default(1)->after('stripe_yearly_price_id'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropColumn('workspace_limit'); + }); + } +}; diff --git a/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php b/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php new file mode 100644 index 000000000..bc5793f6a --- /dev/null +++ b/database/migrations/2026_09_08_173445_drop_monthly_credits_limit_from_plans_table.php @@ -0,0 +1,24 @@ +dropColumn('monthly_credits_limit'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->integer('monthly_credits_limit')->default(2500); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 65b731347..3b9d52974 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -10,20 +10,41 @@ class PlanSeeder extends Seeder { - /** - * Run the database seeds. - */ public function run(): void { + Plan::updateOrCreate( + ['slug' => Slug::Socials], + [ + 'name' => 'Socials', + 'stripe_monthly_price_id' => env('STRIPE_SOCIALS_MONTHLY'), + 'stripe_yearly_price_id' => env('STRIPE_SOCIALS_YEARLY'), + 'workspace_limit' => 1, + 'sort' => 1, + 'is_archived' => false, + ], + ); + + Plan::updateOrCreate( + ['slug' => Slug::Workspaces], + [ + 'name' => 'Workspaces', + 'stripe_monthly_price_id' => env('STRIPE_WORKSPACES_MONTHLY'), + 'stripe_yearly_price_id' => env('STRIPE_WORKSPACES_YEARLY'), + 'workspace_limit' => null, + 'sort' => 2, + 'is_archived' => false, + ], + ); + Plan::updateOrCreate( ['slug' => Slug::Workspace], [ 'name' => 'Workspace', 'stripe_monthly_price_id' => env('STRIPE_WORKSPACE_MONTHLY'), 'stripe_yearly_price_id' => env('STRIPE_WORKSPACE_YEARLY'), - 'monthly_credits_limit' => 2500, - 'sort' => 1, - 'is_archived' => false, + 'workspace_limit' => 1, + 'sort' => 3, + 'is_archived' => true, ], ); } diff --git a/docker/.env.docker.example b/docker/.env.docker.example index 128d437bd..4f214308e 100644 --- a/docker/.env.docker.example +++ b/docker/.env.docker.example @@ -12,7 +12,6 @@ WEBHOOK_URL= SELF_HOSTED=true # Allow more than one connected account per social network in a workspace. -ALLOW_MULTIPLE_SOCIAL_ACCOUNTS=true TELESCOPE_ENABLED=false diff --git a/lang/ar/accounts.php b/lang/ar/accounts.php index 06b29447d..5f9e8f3f8 100644 --- a/lang/ar/accounts.php +++ b/lang/ar/accounts.php @@ -6,6 +6,17 @@ 'description' => 'نظرة عامة على جميع حساباتك الاجتماعية المتصلة', 'connect_cta' => 'ربط', 'connect_another' => 'ربط حساب آخر', + 'actions' => 'إجراءات الحساب', + 'activate' => 'تشغيل', + 'deactivate' => 'إيقاف', + 'active' => 'نشط', + 'paused' => 'متوقف', + 'accounts_count' => ':count حساب|:count حسابات', + + 'variants' => [ + 'linkedin-page' => 'صفحة', + 'instagram-facebook' => 'عبر Facebook', + ], 'not_connected' => 'غير متصل', 'connect' => 'ربط', diff --git a/lang/ar/billing.php b/lang/ar/billing.php index d32643acc..30b571c40 100644 --- a/lang/ar/billing.php +++ b/lang/ar/billing.php @@ -9,57 +9,82 @@ 'cta' => 'تحديث الدفع', ], - 'annual_banner' => [ - 'title' => 'احصل على شهرين مجانًا', - 'description' => 'انتقل إلى الفوترة السنوية وادفع أقل كل شهر — الخطة نفسها، لا شيء آخر يتغير.', - 'cta' => 'الترقية إلى السنوية', - ], - 'subscribe' => [ 'billed_monthly' => 'فوترة شهرية', - 'billed_yearly' => 'فوترة سنوية', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'الخطط', + 'description' => 'يمكنك الترقية أو التخفيض في أي وقت.', + 'monthly' => 'شهري', + 'yearly' => 'سنوي', + 'save_two_months' => 'شهران مجانًا', + 'per_month' => '/شهر', + 'workspaces_one' => 'مساحة عمل واحدة', + 'workspaces_unlimited' => 'مساحات عمل غير محدودة', + 'workspaces_tooltip' => 'مساحة العمل هي علامة تجارية أو عميل واحد، منفصلة عن البقية: بحساباتها الاجتماعية وتوقيعاتها وتصنيفاتها وتحليلاتها وصلاحيات أعضائها واتصال MCP الخاص بها.', + 'current' => 'الخطة الحالية', + 'switch_to_yearly' => 'التبديل إلى السنوي', + 'switch_to_monthly' => 'التبديل إلى الشهري', + 'select' => 'اختر :plan', + 'upgrade' => 'الترقية إلى :plan', + 'downgrade' => 'الرجوع إلى :plan', + 'start_first_month' => 'ابدأ مقابل :price', + 'per_first_month' => '/الشهر الأول', + 'then_monthly' => 'ثم :price/شهر', + 'billed_yearly_total' => 'فوترة سنوية · :price (شهران مجانًا)', + 'socials_tagline' => 'مناسب لمنشئي المحتوى والعلامات الصغيرة.', + 'workspaces_tagline' => 'مناسب للوكالات والأعمال الكبيرة.', + 'everything_included' => 'كل شيء مشمول', + 'features' => [ + 'networks_all' => 'جميع الشبكات الاجتماعية مشمولة', + 'networks_all_tooltip' => 'يمكنك النشر على كل هذه الشبكات.', + 'accounts_unlimited' => 'حسابات اجتماعية غير محدودة', + 'accounts_unlimited_tooltip' => 'اربط أي عدد من الحسابات، حتى عدة حسابات من الشبكة نفسها. ثلاثة حسابات Instagram مثلًا.', + 'calendar' => 'تقويم شهري وأسبوعي ويومي', + 'calendar_tooltip' => 'شاهد شهرك كاملاً بنظرة واحدة: ما هو مخطط، ومجدول، وما نُشر بالفعل. انتقل إلى عرض الأسبوع أو اليوم عندما تحتاج إلى التفاصيل.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'مساعدك بالذكاء الاصطناعي لكتابة المنشورات ومراجعتها.', + 'mcp' => 'MCP: انشر من Claude أو ChatGPT أو Grok', + 'mcp_tooltip' => 'اربط Claude أو ChatGPT أو Grok بمساحة عملك. اطلب منه إنشاء المنشورات وجدولتها، وجلب المقاييس، ومعرفة الأفضل أداءً، والتخطيط للمحتوى التالي بناءً على بياناتك.', + 'repurpose' => 'Repurpose: حوّل منشورًا واحدًا إلى عدة منشورات', + 'repurpose_tooltip' => 'اختر حسابًا مصدرًا. كل منشور جديد تنشره هناك يُعاد نشره تلقائيًا على شبكاتك الأخرى. لا تحتاج إلى فتح TryPost.', + 'analytics' => 'التحليلات', + 'analytics_tooltip' => 'احصل على مقاييس مثل مرات الظهور والوصول والإعجابات والتعليقات لكل منشور ولكل حساب، كلها في مكان واحد.', + 'team' => 'أعضاء بلا حدود', + 'team_tooltip' => 'ادعُ أي عدد من الأشخاص إلى فريقك دون تكلفة إضافية. حدّد ما يمكن لكل شخص فعله ووافق على المنشورات قبل نشرها.', ], ], 'plan' => [ - 'title' => 'الخطة', - 'description' => 'إدارة خطة اشتراكك.', - 'label' => 'الخطة', - 'workspaces' => '{1}مساحة عمل واحدة|{2}مساحتا عمل|[3,10]:count مساحات عمل|[11,*]:count مساحة عمل', - 'per_workspace' => 'لكل مساحة عمل', - 'price' => 'السعر', - 'month' => 'شهر', 'trial' => 'تجريبي', - 'active' => 'نشط', - 'past_due' => 'متأخر', 'cancelling' => 'قيد الإلغاء', 'trial_ends' => 'تنتهي الفترة التجريبية', ], 'subscription' => [ - 'title' => 'الاشتراك', - 'description' => 'إدارة طريقة الدفع وتفاصيل الفوترة والاشتراك.', - 'payment_method' => 'طريقة الدفع', + 'title' => 'طريقة الدفع', + 'description' => 'حدّث بطاقتك أو بيانات الفوترة على Stripe.', 'no_payment_method' => 'لا توجد طريقة دفع مسجّلة بعد.', 'expires_on' => 'تنتهي في :month/:year', - 'manage_label' => 'الاشتراك', 'manage_stripe' => 'الإدارة عبر Stripe', ], 'invoices' => [ 'title' => 'الفواتير', 'description' => 'نزّل فواتيرك السابقة.', - 'empty' => 'لم يتم العثور على فواتير', 'paid' => 'مدفوعة', ], 'flash' => [ 'plan_changed' => 'أنت الآن على خطة :plan.', - 'switched_to_yearly' => 'أنت الآن على الفوترة السنوية.', 'cannot_manage' => 'يمكن لمالك الحساب فقط إدارة الفوترة.', - 'credits_exhausted' => 'نفد رصيد الذكاء الاصطناعي — تم استخدام مخصصك الشهري البالغ :limit. رقِّ خطتك أو انتظر حتى الشهر المقبل.', + 'too_many_workspaces' => 'لديك :count من مساحات العمل. تتضمن هذه الخطة :limit — احذف الزائدة قبل التبديل.', 'subscription_required' => 'يلزم وجود اشتراك نشط لاستخدام ميزات الذكاء الاصطناعي.', ], @@ -67,10 +92,5 @@ 'page_title' => 'جارٍ المعالجة...', 'title' => 'جارٍ معالجة اشتراكك', 'description' => 'يرجى الانتظار بينما نُعِدّ حسابك. لن يستغرق هذا سوى لحظة.', - 'success_title' => 'كل شيء جاهز!', - 'success_description' => 'اشتراكك نشط. جارٍ إعادة توجيهك إلى مساحات عملك...', - 'cancelled_title' => 'تم إلغاء الدفع', - 'cancelled_description' => 'تم إلغاء عملية الدفع. لم تُجرَ أي رسوم.', - 'retry' => 'إعادة المحاولة', ], ]; diff --git a/lang/ar/onboarding.php b/lang/ar/onboarding.php deleted file mode 100644 index 5c6aca6df..000000000 --- a/lang/ar/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'البدء', - 'welcome' => 'مرحبًا بك في TryPost، :name', - 'description' => 'اتبع الخطوات أدناه لمعرفة كيف يعمل TryPost ونشر منشورك الأول.', - 'skip_step' => 'تخطي هذه الخطوة', - 'continue' => 'المتابعة إلى TryPost', - 'status' => [ - 'complete' => 'مكتمل', - 'todo' => 'مطلوب', - 'skipped' => 'تم تخطيه', - ], - 'mcp' => [ - 'title' => 'اربط مساعد الذكاء الاصطناعي', - 'description' => 'أضِف TryPost كخادم MCP ليتمكّن مساعدك من إنشاء منشورات التواصل وإدارتها نيابةً عنك.', - 'copied' => 'تم نسخ رابط MCP.', - ], - 'social' => [ - 'title' => 'اربط حسابًا اجتماعيًا', - 'description' => 'اختر شبكة واحدة واحدة على الأقل يمكن لـ TryPost النشر عليها.', - 'connected_elsewhere' => 'لقد ربطت حسابًا في مساحة عمل أخرى بالفعل، لذا اكتملت هذه الخطوة.', - ], - 'first_post' => [ - 'title' => 'أنشئ منشورك الأول', - 'description' => 'جرّب هذا الموجّه مع مساعدك المتصل، أو أنشئ المنشور مباشرة في TryPost.', - 'prompt_label' => 'موجّه نموذجي', - 'sample_prompt' => 'أنشئ منشورًا اجتماعيًا ودّيًا يعرّف بعلامتي التجارية وكيّفه لكل شبكة متصلة.', - 'copy_prompt' => 'نسخ الموجّه', - 'copied' => 'تم نسخ الموجّه النموذجي.', - 'create_button' => 'إنشاء منشورك الأول', - 'or' => 'أو', - ], - 'ready' => [ - 'title' => 'أنت جاهز للنشر', - 'description' => 'كل شيء جاهز. تابع إلى TryPost وابدأ بتخطيط محتواك.', - ], -]; diff --git a/lang/ar/settings.php b/lang/ar/settings.php index d3dc72c88..624911023 100644 --- a/lang/ar/settings.php +++ b/lang/ar/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'الحساب', - 'usage' => 'الاستخدام', 'billing' => 'الفوترة', ], 'title' => 'إعدادات الحساب', diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index a424c1c10..cde95d7dd 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'الحساب والفوترة', 'workspace_settings' => 'إعدادات مساحة العمل', 'log_out' => 'تسجيل الخروج', + 'help' => 'مساعدة', 'workspace' => [ 'connections' => 'الاتصالات', 'signatures' => 'التوقيعات', @@ -27,8 +28,6 @@ ], 'analytics' => 'التحليلات', 'repurposes' => 'Repurpose', - 'onboarding' => 'البدء', - 'onboarding_hint' => 'أكمل الإعداد', 'posts' => [ 'calendar' => 'التقويم', 'all' => 'الكل', @@ -42,8 +41,10 @@ 'archive_all' => 'أرشفة الكل', 'no_notifications' => 'لا توجد إشعارات', 'support' => [ + 'chat' => 'الدعم عبر الدردشة', 'docs' => 'التوثيق', 'referral' => 'اربح عمولة إحالة 30%', 'discord' => 'مجتمع Discord', + 'community' => 'المجتمع', ], ]; diff --git a/lang/ar/usage.php b/lang/ar/usage.php deleted file mode 100644 index fb709054c..000000000 --- a/lang/ar/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'الاستخدام', - - 'section_account' => 'الحساب', - 'section_account_description' => 'الحصص والحدود الخاصة بخطتك.', - 'section_ai' => 'رصيد الذكاء الاصطناعي', - 'section_ai_description' => 'يُخصم الرصيد عند استخدام ميزات الذكاء الاصطناعي. يتم تجديده في اليوم الأول من كل شهر.', - - 'workspaces' => 'مساحات العمل', - 'social_accounts' => 'الحسابات الاجتماعية', - 'members' => 'الأعضاء', - 'credits' => 'الرصيد', -]; diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php index 9b64c5b3c..c48df77be 100644 --- a/lang/ar/welcome.php +++ b/lang/ar/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'مالك حسابك هو :name.', 'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.', 'progress' => 'تقدم الترحيب', - 'go_to_step' => 'الانتقال إلى الخطوة :step', - 'step_current' => 'الخطوة :step (الحالية)', + 'step_of' => 'الخطوة :step من :total', + 'back' => 'رجوع', + 'steps' => [ + 'persona' => 'عنك', + 'goals' => 'أهدافك', + 'referral_source' => 'كيف وجدتنا', + 'connect' => 'الشبكات الاجتماعية', + 'plan' => 'الخطة', + ], + 'preview' => [ + 'heading' => 'مساحة عملك تتشكّل.', + 'pending' => 'لم يُختَر بعد', + 'networks_empty' => 'لا توجد شبكات متصلة بعد', + ], 'personas' => [ 'creator' => 'صانع محتوى', 'freelancer' => 'مستقل', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', 'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي', - 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor', + 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT وغيرها', 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', @@ -38,6 +50,8 @@ 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', 'other' => 'شيء آخر', ], + 'plan_title' => 'اختر خطتك', + 'plan_description' => 'الخطتان تحويان الميزات نفسها. الفرق في عدد مساحات العمل، ويمكنك تغيير الخطة في أي وقت.', 'referral_source_title' => 'كيف وجدتنا؟', 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', 'referral_source' => [ diff --git a/lang/ar/workspaces.php b/lang/ar/workspaces.php index d1347a915..2bc164518 100644 --- a/lang/ar/workspaces.php +++ b/lang/ar/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'الحالية', 'connections' => ':count اتصال', 'posts' => ':count منشور', + 'subscription_required' => 'اشترك لإنشاء المزيد من مساحات العمل.', + 'limit_reached' => 'تتضمن خطتك مساحة عمل واحدة. قم بالترقية لإضافة المزيد.', + + 'upgrade_dialog' => [ + 'title' => 'رقِّ خطتك لإضافة مساحات عمل', + 'description' => 'خطتك تتضمن مساحة عمل واحدة. رقِّ إلى Workspaces للحصول على مساحات عمل غير محدودة — واحدة لكل علامة أو عميل.', + ], 'create' => [ 'page_title' => 'أنشئ مساحة عملك', diff --git a/lang/de/accounts.php b/lang/de/accounts.php index 0fa250d39..3fd7bee0e 100644 --- a/lang/de/accounts.php +++ b/lang/de/accounts.php @@ -8,6 +8,17 @@ 'description' => 'Übersicht über alle deine verbundenen Social-Media-Konten', 'connect_cta' => 'Verbinden', 'connect_another' => 'Weitere verbinden', + 'actions' => 'Kontoaktionen', + 'activate' => 'Aktivieren', + 'deactivate' => 'Deaktivieren', + 'active' => 'Aktiv', + 'paused' => 'Deaktiviert', + 'accounts_count' => ':count Konto|:count Konten', + + 'variants' => [ + 'linkedin-page' => 'Seite', + 'instagram-facebook' => 'über Facebook', + ], 'not_connected' => 'Nicht verbunden', 'connect' => 'Verbinden', diff --git a/lang/de/billing.php b/lang/de/billing.php index b977e9f3c..e072c691c 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -11,57 +11,82 @@ 'cta' => 'Zahlung aktualisieren', ], - 'annual_banner' => [ - 'title' => '2 Monate gratis erhalten', - 'description' => 'Wechsle zur jährlichen Abrechnung und zahle jeden Monat weniger – gleicher Tarif, sonst ändert sich nichts.', - 'cta' => 'Auf jährlich upgraden', - ], - 'subscribe' => [ 'billed_monthly' => 'Monatlich abgerechnet', - 'billed_yearly' => 'Jährlich abgerechnet', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Tarife', + 'description' => 'Jederzeit upgraden oder downgraden.', + 'monthly' => 'Monatlich', + 'yearly' => 'Jährlich', + 'save_two_months' => '2 Monate gratis', + 'per_month' => '/Monat', + '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.', + 'current' => 'Aktueller Tarif', + 'switch_to_yearly' => 'Zu jährlich wechseln', + 'switch_to_monthly' => 'Zu monatlich wechseln', + 'select' => ':plan wählen', + 'upgrade' => 'Auf :plan upgraden', + 'downgrade' => 'Auf :plan downgraden', + 'start_first_month' => 'Für :price starten', + 'per_first_month' => '/erster Monat', + 'then_monthly' => 'Danach :price/Monat', + 'billed_yearly_total' => 'Jährlich abgerechnet · :price (2 Monate gratis)', + 'socials_tagline' => 'Ideal für Creator und kleine Marken.', + 'workspaces_tagline' => 'Ideal für Agenturen und größere Unternehmen.', + 'everything_included' => 'Alles inklusive', + 'features' => [ + 'networks_all' => 'Alle sozialen Netzwerke inklusive', + 'networks_all_tooltip' => 'Du kannst auf allen diesen Netzwerken posten.', + 'accounts_unlimited' => 'Unbegrenzte Social-Accounts', + 'accounts_unlimited_tooltip' => 'Verbinde so viele Konten, wie du willst, auch mehrere vom selben Netzwerk. Zum Beispiel drei Instagram-Konten.', + 'calendar' => 'Kalender: Monats-, Wochen- und Tagesansicht', + 'calendar_tooltip' => 'Sieh deinen ganzen Monat auf einen Blick: was geplant, terminiert und schon veröffentlicht ist. Wechsle zu Woche oder Tag, wenn du Details brauchst.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Dein KI-Assistent zum Schreiben und Überarbeiten von Posts.', + 'mcp' => 'MCP: posten mit Claude, ChatGPT oder Grok', + 'mcp_tooltip' => 'Verbinde Claude, ChatGPT oder Grok mit deinem Workspace. Lass es Posts erstellen und planen, Kennzahlen abrufen, die besten Posts erkennen und aus deinen Daten den nächsten Content planen.', + 'repurpose' => 'Repurpose: aus einem Post viele machen', + 'repurpose_tooltip' => 'Wähle ein Quellkonto. Jeder neue Post, den du dort veröffentlichst, wird automatisch auf deinen anderen Netzwerken veröffentlicht. Du musst TryPost nicht öffnen.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Hol dir Kennzahlen wie Impressionen, Reichweite, Likes und Kommentare für jeden Post und jedes Konto, alles an einem Ort.', + 'team' => 'Unbegrenzte Mitglieder', + 'team_tooltip' => 'Lade so viele Personen in dein Team ein, wie du willst, ohne Aufpreis. Lege fest, was jede Person darf, und gib Posts vor der Veröffentlichung frei.', ], ], 'plan' => [ - 'title' => 'Tarif', - 'description' => 'Verwalte deinen Abonnement-Tarif.', - 'label' => 'Tarif', - 'workspaces' => '{1}:count Workspace|[2,*]:count Workspaces', - 'per_workspace' => 'pro Workspace', - 'price' => 'Preis', - 'month' => 'Monat', 'trial' => 'Testphase', - 'active' => 'Aktiv', - 'past_due' => 'Überfällig', 'cancelling' => 'Wird gekündigt', 'trial_ends' => 'Testphase endet', ], 'subscription' => [ - 'title' => 'Abonnement', - 'description' => 'Verwalte deine Zahlungsmethode, Rechnungsdaten und dein Abonnement.', - 'payment_method' => 'Zahlungsmethode', + 'title' => 'Zahlungsmethode', + 'description' => 'Aktualisiere deine Karte oder Rechnungsdaten bei Stripe.', 'no_payment_method' => 'Noch keine Zahlungsmethode hinterlegt.', 'expires_on' => 'Läuft ab :month/:year', - 'manage_label' => 'Abonnement', 'manage_stripe' => 'Bei Stripe verwalten', ], 'invoices' => [ 'title' => 'Rechnungen', 'description' => 'Lade deine bisherigen Rechnungen herunter.', - 'empty' => 'Keine Rechnungen gefunden', 'paid' => 'Bezahlt', ], 'flash' => [ 'plan_changed' => 'Du nutzt jetzt den Tarif :plan.', - 'switched_to_yearly' => 'Du nutzt jetzt die jährliche Abrechnung.', 'cannot_manage' => 'Nur der Kontoinhaber kann die Abrechnung verwalten.', - 'credits_exhausted' => 'Keine KI-Credits mehr – dein monatliches Kontingent von :limit ist aufgebraucht. Führe ein Upgrade durch oder warte bis zum nächsten Monat.', + 'too_many_workspaces' => 'Du hast :count Workspaces. Dieser Tarif umfasst :limit — lösche die überzähligen, bevor du wechselst.', 'subscription_required' => 'Für die Nutzung der KI-Funktionen ist ein aktives Abonnement erforderlich.', ], @@ -69,10 +94,5 @@ 'page_title' => 'Wird verarbeitet...', 'title' => 'Dein Abonnement wird verarbeitet', 'description' => 'Bitte warte, während wir dein Konto einrichten. Das dauert nur einen Moment.', - 'success_title' => 'Alles bereit!', - 'success_description' => 'Dein Abonnement ist aktiv. Du wirst zu deinen Workspaces weitergeleitet...', - 'cancelled_title' => 'Bezahlvorgang abgebrochen', - 'cancelled_description' => 'Dein Bezahlvorgang wurde abgebrochen. Es wurden keine Kosten berechnet.', - 'retry' => 'Erneut versuchen', ], ]; diff --git a/lang/de/onboarding.php b/lang/de/onboarding.php deleted file mode 100644 index 1e91ebd02..000000000 --- a/lang/de/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Erste Schritte', - 'welcome' => 'Willkommen bei TryPost, :name', - 'description' => 'Folge den Schritten unten, um zu sehen, wie TryPost funktioniert, und deinen ersten Beitrag zu veröffentlichen.', - 'skip_step' => 'Diesen Schritt überspringen', - 'continue' => 'Weiter zu TryPost', - 'status' => [ - 'complete' => 'Erledigt', - 'todo' => 'Offen', - 'skipped' => 'Übersprungen', - ], - 'mcp' => [ - 'title' => 'Verbinde deinen KI-Assistenten', - 'description' => 'Füge TryPost als MCP-Server hinzu, damit dein Assistent Social-Beiträge für dich erstellen und verwalten kann.', - 'copied' => 'MCP-URL kopiert.', - ], - 'social' => [ - 'title' => 'Verbinde ein Social-Konto', - 'description' => 'Wähle mindestens ein Netzwerk, in dem TryPost deine Inhalte veröffentlichen kann.', - 'connected_elsewhere' => 'Du hast bereits ein Konto in einem anderen Workspace verbunden — dieser Schritt ist erledigt.', - ], - 'first_post' => [ - 'title' => 'Erstelle deinen ersten Beitrag', - 'description' => 'Probiere diesen Starter-Prompt mit deinem verbundenen Assistenten aus, oder erstelle den Beitrag direkt in TryPost.', - 'prompt_label' => 'Beispiel-Prompt', - 'sample_prompt' => 'Erstelle einen freundlichen Social-Beitrag, der meine Marke vorstellt, und passe ihn für jedes verbundene Netzwerk an.', - 'copy_prompt' => 'Prompt kopieren', - 'copied' => 'Beispiel-Prompt kopiert.', - 'create_button' => 'Ersten Beitrag erstellen', - 'or' => 'oder', - ], - 'ready' => [ - 'title' => 'Du bist bereit zum Veröffentlichen', - 'description' => 'Alles klar. Weiter zu TryPost und plane deine Inhalte.', - ], -]; diff --git a/lang/de/settings.php b/lang/de/settings.php index e1bfc9991..6e5028a20 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -313,7 +313,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Konto', - 'usage' => 'Nutzung', 'billing' => 'Abrechnung', ], 'title' => 'Kontoeinstellungen', diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index 0807a91a6..ed74bae2b 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Konto & Abrechnung', 'workspace_settings' => 'Workspace-Einstellungen', 'log_out' => 'Abmelden', + 'help' => 'Hilfe', 'workspace' => [ 'connections' => 'Verbindungen', 'signatures' => 'Signaturen', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analytics', 'repurposes' => 'Repurpose', - 'onboarding' => 'Erste Schritte', - 'onboarding_hint' => 'Einrichtung abschließen', 'posts' => [ 'calendar' => 'Kalender', 'all' => 'Alle', @@ -42,8 +41,10 @@ 'archive_all' => 'Alle archivieren', 'no_notifications' => 'Keine Benachrichtigungen', 'support' => [ + 'chat' => 'Chat-Support', 'docs' => 'Dokumentation', 'referral' => '30% Provision verdienen', 'discord' => 'Discord-Community', + 'community' => 'Community', ], ]; diff --git a/lang/de/usage.php b/lang/de/usage.php deleted file mode 100644 index fd6cf4de0..000000000 --- a/lang/de/usage.php +++ /dev/null @@ -1,17 +0,0 @@ - 'Nutzung', - - 'section_account' => 'Konto', - 'section_account_description' => 'Kontingente und Limits für deinen Tarif.', - 'section_ai' => 'KI-Credits', - 'section_ai_description' => 'Credits werden bei der Nutzung von KI-Funktionen abgezogen. Sie werden am Ersten jedes Monats zurückgesetzt.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Social-Media-Konten', - 'members' => 'Mitglieder', - 'credits' => 'Credits', -]; diff --git a/lang/de/welcome.php b/lang/de/welcome.php index 4fbdf7264..0cdd40e81 100644 --- a/lang/de/welcome.php +++ b/lang/de/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Der Kontoinhaber ist :name.', 'subscription_required_auto' => 'Diese Seite aktualisiert sich automatisch — kein Neuladen nötig.', 'progress' => 'Willkommensfortschritt', - 'go_to_step' => 'Zu Schritt :step gehen', - 'step_current' => 'Schritt :step (aktuell)', + 'step_of' => 'Schritt :step von :total', + 'back' => 'Zurück', + 'steps' => [ + 'persona' => 'Über dich', + 'goals' => 'Deine Ziele', + 'referral_source' => 'Wie du uns gefunden hast', + 'connect' => 'Soziale Netzwerke', + 'plan' => 'Tarif', + ], + 'preview' => [ + 'heading' => 'Dein Workspace nimmt Gestalt an.', + 'pending' => 'Noch nicht gewählt', + 'networks_empty' => 'Noch keine Netzwerke verbunden', + ], 'personas' => [ 'creator' => 'Content Creator', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Zeit sparen, indem ich überall gleichzeitig poste', 'ai_content' => 'Beiträge mit TryPost-KI erstellen', - 'use_mcp' => 'Beiträge über Claude, ChatGPT oder Cursor erstellen', + 'use_mcp' => 'Beiträge über Claude, ChatGPT usw. erstellen', 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', @@ -38,6 +50,8 @@ 'just_exploring' => 'Ich schaue mich vorerst nur um', 'other' => 'Etwas anderes', ], + 'plan_title' => 'Wähle deinen Tarif', + 'plan_description' => 'Beide Pläne haben dieselben Funktionen. Der Unterschied ist die Anzahl der Workspaces, und du kannst jederzeit wechseln.', 'referral_source_title' => 'Wie hast du uns gefunden?', 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', 'referral_source' => [ diff --git a/lang/de/workspaces.php b/lang/de/workspaces.php index f5eac38e4..3f543ead3 100644 --- a/lang/de/workspaces.php +++ b/lang/de/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Aktuell', 'connections' => ':count Verbindungen', 'posts' => ':count Beiträge', + 'subscription_required' => 'Abonniere, um weitere Workspaces zu erstellen.', + 'limit_reached' => 'Dein Tarif umfasst einen Workspace. Upgrade, um weitere hinzuzufügen.', + + 'upgrade_dialog' => [ + 'title' => 'Upgrade, um Workspaces hinzuzufügen', + 'description' => 'Dein Plan enthält einen Workspace. Upgrade auf Workspaces für unbegrenzt viele Workspaces — einen pro Marke oder Kunden.', + ], 'create' => [ 'page_title' => 'Erstelle deinen Workspace', diff --git a/lang/el/accounts.php b/lang/el/accounts.php index 4d225a482..7623f1e5b 100644 --- a/lang/el/accounts.php +++ b/lang/el/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Επισκόπηση όλων των συνδεδεμένων λογαριασμών κοινωνικών δικτύων σας', 'connect_cta' => 'Σύνδεση', 'connect_another' => 'Σύνδεση άλλου', + 'actions' => 'Ενέργειες λογαριασμού', + 'activate' => 'Ενεργοποίηση', + 'deactivate' => 'Απενεργοποίηση', + 'active' => 'Ενεργός', + 'paused' => 'Ανενεργός', + 'accounts_count' => ':count λογαριασμός|:count λογαριασμοί', + + 'variants' => [ + 'linkedin-page' => 'Σελίδα', + 'instagram-facebook' => 'μέσω Facebook', + ], 'not_connected' => 'Μη συνδεδεμένος', 'connect' => 'Σύνδεση', diff --git a/lang/el/billing.php b/lang/el/billing.php index fa643fe71..1296350a1 100644 --- a/lang/el/billing.php +++ b/lang/el/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Ενημέρωση πληρωμής', ], - 'annual_banner' => [ - 'title' => 'Κερδίστε 2 μήνες δωρεάν', - 'description' => 'Μεταβείτε σε ετήσια χρέωση και πληρώνετε λιγότερα κάθε μήνα — ίδιο πρόγραμμα, τίποτα άλλο δεν αλλάζει.', - 'cta' => 'Αναβάθμιση σε ετήσιο', - ], - 'subscribe' => [ 'billed_monthly' => 'Μηνιαία χρέωση', - 'billed_yearly' => 'Ετήσια χρέωση', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Πλάνα', + 'description' => 'Αναβαθμίστε ή υποβαθμίστε οποιαδήποτε στιγμή.', + 'monthly' => 'Μηνιαία', + 'yearly' => 'Ετήσια', + 'save_two_months' => '2 μήνες δωρεάν', + 'per_month' => '/μήνα', + 'workspaces_one' => 'Ένα workspace', + 'workspaces_unlimited' => 'Απεριόριστα workspaces', + 'workspaces_tooltip' => 'Ένα workspace είναι ένα brand ή ένας πελάτης, ξεχωριστά από τα υπόλοιπα: με δικούς του λογαριασμούς social, υπογραφές, ετικέτες, analytics, δικαιώματα μελών και σύνδεση MCP.', + 'current' => 'Τρέχον πλάνο', + 'switch_to_yearly' => 'Μετάβαση σε ετήσιο', + 'switch_to_monthly' => 'Μετάβαση σε μηνιαίο', + 'select' => 'Επιλέξτε :plan', + 'upgrade' => 'Αναβάθμιση σε :plan', + 'downgrade' => 'Υποβάθμιση σε :plan', + 'start_first_month' => 'Ξεκίνα με :price', + 'per_first_month' => '/πρώτος μήνας', + 'then_monthly' => 'Μετά :price/μήνα', + 'billed_yearly_total' => 'Ετήσια χρέωση · :price (2 μήνες δωρεάν)', + 'socials_tagline' => 'Ιδανικό για creators και μικρά brands.', + 'workspaces_tagline' => 'Ιδανικό για agencies και μεγαλύτερες επιχειρήσεις.', + 'everything_included' => 'Όλα περιλαμβάνονται', + 'features' => [ + 'networks_all' => 'Όλα τα κοινωνικά δίκτυα περιλαμβάνονται', + 'networks_all_tooltip' => 'Μπορείτε να δημοσιεύετε σε όλα αυτά τα δίκτυα.', + 'accounts_unlimited' => 'Απεριόριστοι λογαριασμοί social', + 'accounts_unlimited_tooltip' => 'Συνδέστε όσους λογαριασμούς θέλετε, ακόμη και πολλούς από το ίδιο δίκτυο. Τρία Instagram, για παράδειγμα.', + 'calendar' => 'Ημερολόγιο: μήνας, εβδομάδα και ημέρα', + 'calendar_tooltip' => 'Δείτε όλο τον μήνα με μια ματιά: τι είναι σχεδιασμένο, προγραμματισμένο και ήδη δημοσιευμένο. Περάστε σε εβδομάδα ή ημέρα όταν χρειάζεστε λεπτομέρεια.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Ο βοηθός AI σας για να γράφετε και να ελέγχετε posts.', + 'mcp' => 'MCP: δημοσίευση από Claude, ChatGPT ή Grok', + 'mcp_tooltip' => 'Συνδέστε το Claude, το ChatGPT ή το Grok στο workspace σας. Ζητήστε του να δημιουργεί και να προγραμματίζει posts, να φέρνει μετρήσεις, να βρίσκει τι πήγε καλύτερα και να σχεδιάζει το επόμενο περιεχόμενο από τα δικά σας δεδομένα.', + 'repurpose' => 'Repurpose: ένα post γίνεται πολλά', + 'repurpose_tooltip' => 'Επιλέξτε έναν λογαριασμό-πηγή. Κάθε νέο post που δημοσιεύετε εκεί αναδημοσιεύεται αυτόματα στα άλλα σας δίκτυα. Δεν χρειάζεται να ανοίξετε το TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Δείτε μετρήσεις όπως εμφανίσεις, απήχηση, likes και σχόλια για κάθε post και κάθε λογαριασμό, όλα σε ένα μέρος.', + 'team' => 'Απεριόριστα μέλη', + 'team_tooltip' => 'Προσκαλέστε όσα άτομα θέλετε στην ομάδα σας, χωρίς επιπλέον κόστος. Ορίστε τι μπορεί να κάνει ο καθένας και εγκρίνετε τα posts πριν δημοσιευτούν.', ], ], 'plan' => [ - 'title' => 'Πρόγραμμα', - 'description' => 'Διαχειριστείτε το πρόγραμμα συνδρομής σας.', - 'label' => 'Πρόγραμμα', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'ανά workspace', - 'price' => 'Τιμή', - 'month' => 'μήνας', 'trial' => 'Δοκιμαστική περίοδος', - 'active' => 'Ενεργό', - 'past_due' => 'Ληξιπρόθεσμο', 'cancelling' => 'Ακυρώνεται', 'trial_ends' => 'Η δοκιμαστική περίοδος λήγει', ], 'subscription' => [ - 'title' => 'Συνδρομή', - 'description' => 'Διαχειριστείτε τη μέθοδο πληρωμής, τα στοιχεία χρέωσης και τη συνδρομή σας.', - 'payment_method' => 'Μέθοδος πληρωμής', + 'title' => 'Μέθοδος πληρωμής', + 'description' => 'Ενημερώστε την κάρτα ή τα στοιχεία χρέωσης στο Stripe.', 'no_payment_method' => 'Δεν υπάρχει καταχωρημένη μέθοδος πληρωμής ακόμη.', 'expires_on' => 'Λήγει :month/:year', - 'manage_label' => 'Συνδρομή', 'manage_stripe' => 'Διαχείριση στο Stripe', ], 'invoices' => [ 'title' => 'Τιμολόγια', 'description' => 'Κατεβάστε τα προηγούμενα τιμολόγιά σας.', - 'empty' => 'Δεν βρέθηκαν τιμολόγια', 'paid' => 'Πληρωμένο', ], 'flash' => [ 'plan_changed' => 'Είστε πλέον στο πρόγραμμα :plan.', - 'switched_to_yearly' => 'Είστε πλέον σε ετήσια χρέωση.', 'cannot_manage' => 'Μόνο ο κάτοχος του λογαριασμού μπορεί να διαχειρίζεται τη χρέωση.', - 'credits_exhausted' => 'Εξαντλήθηκαν τα credits AI — το μηνιαίο σας όριο των :limit έχει χρησιμοποιηθεί. Αναβαθμίστε το πρόγραμμά σας ή περιμένετε μέχρι τον επόμενο μήνα.', + 'too_many_workspaces' => 'Έχετε :count workspaces. Αυτό το πλάνο περιλαμβάνει :limit — διαγράψτε τα επιπλέον πριν αλλάξετε.', 'subscription_required' => 'Απαιτείται ενεργή συνδρομή για τη χρήση των λειτουργιών AI.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Επεξεργασία...', 'title' => 'Επεξεργασία της συνδρομής σας', 'description' => 'Παρακαλούμε περιμένετε όσο ρυθμίζουμε τον λογαριασμό σας. Θα πάρει μόνο μια στιγμή.', - 'success_title' => 'Είστε έτοιμοι!', - 'success_description' => 'Η συνδρομή σας είναι ενεργή. Σας ανακατευθύνουμε στα workspaces σας...', - 'cancelled_title' => 'Η πληρωμή ακυρώθηκε', - 'cancelled_description' => 'Η πληρωμή σας ακυρώθηκε. Δεν έγινε καμία χρέωση.', - 'retry' => 'Δοκιμάστε ξανά', ], ]; diff --git a/lang/el/onboarding.php b/lang/el/onboarding.php deleted file mode 100644 index be6f33191..000000000 --- a/lang/el/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Ξεκινώντας', - 'welcome' => 'Καλώς ήρθες στο TryPost, :name', - 'description' => 'Ακολούθησε τα παρακάτω βήματα για να δεις πώς λειτουργεί το TryPost και να δημοσιεύσεις την πρώτη σου ανάρτηση.', - 'skip_step' => 'Παράλειψη αυτού του βήματος', - 'continue' => 'Συνέχεια στο TryPost', - 'status' => [ - 'complete' => 'Ολοκληρώθηκε', - 'todo' => 'Εκκρεμεί', - 'skipped' => 'Παραλείφθηκε', - ], - 'mcp' => [ - 'title' => 'Σύνδεσε τον βοηθό AI σου', - 'description' => 'Πρόσθεσε το TryPost ως διακομιστή MCP ώστε ο βοηθός σου να δημιουργεί και να διαχειρίζεται social posts για εσένα.', - 'copied' => 'Το URL MCP αντιγράφηκε.', - ], - 'social' => [ - 'title' => 'Σύνδεσε έναν λογαριασμό social', - 'description' => 'Διάλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.', - 'connected_elsewhere' => 'Έχεις ήδη συνδέσει λογαριασμό σε άλλο workspace, οπότε αυτό το βήμα ολοκληρώθηκε.', - ], - 'first_post' => [ - 'title' => 'Δημιούργησε την πρώτη σου ανάρτηση', - 'description' => 'Δοκίμασε αυτό το αρχικό prompt με τον συνδεδεμένο βοηθό σου ή δημιούργησε την ανάρτηση απευθείας στο TryPost.', - 'prompt_label' => 'Δείγμα prompt', - 'sample_prompt' => 'Δημιούργησε μια φιλική social ανάρτηση που παρουσιάζει το brand μου και προσαρμοσέ την για κάθε συνδεδεμένο δίκτυο.', - 'copy_prompt' => 'Αντιγραφή prompt', - 'copied' => 'Το δείγμα prompt αντιγράφηκε.', - 'create_button' => 'Δημιούργησε την πρώτη σου ανάρτηση', - 'or' => 'ή', - ], - 'ready' => [ - 'title' => 'Είσαι έτοιμος να δημοσιεύσεις', - 'description' => 'Όλα είναι έτοιμα. Συνέχισε στο TryPost και ξεκίνα να σχεδιάζεις το περιεχόμενό σου.', - ], -]; diff --git a/lang/el/settings.php b/lang/el/settings.php index 3ce5941f8..1eb539c85 100644 --- a/lang/el/settings.php +++ b/lang/el/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Λογαριασμός', - 'usage' => 'Χρήση', 'billing' => 'Χρέωση', ], 'title' => 'Ρυθμίσεις λογαριασμού', diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index 9e3db2be6..324f56fe2 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Λογαριασμός και χρέωση', 'workspace_settings' => 'Ρυθμίσεις workspace', 'log_out' => 'Αποσύνδεση', + 'help' => 'Βοήθεια', 'workspace' => [ 'connections' => 'Συνδέσεις', 'signatures' => 'Υπογραφές', @@ -27,8 +28,6 @@ ], 'analytics' => 'Στατιστικά', 'repurposes' => 'Repurpose', - 'onboarding' => 'Ξεκινώντας', - 'onboarding_hint' => 'Ολοκλήρωση ρύθμισης', 'posts' => [ 'calendar' => 'Ημερολόγιο', 'all' => 'Όλες', @@ -42,8 +41,10 @@ 'archive_all' => 'Αρχειοθέτηση όλων', 'no_notifications' => 'Δεν υπάρχουν ειδοποιήσεις', 'support' => [ + 'chat' => 'Υποστήριξη μέσω chat', 'docs' => 'Τεκμηρίωση', 'referral' => 'Κερδίστε 30% από συστάσεις', 'discord' => 'Κοινότητα Discord', + 'community' => 'Κοινότητα', ], ]; diff --git a/lang/el/usage.php b/lang/el/usage.php deleted file mode 100644 index 4b6946455..000000000 --- a/lang/el/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Χρήση', - - 'section_account' => 'Λογαριασμός', - 'section_account_description' => 'Όρια και ποσοστώσεις για το πρόγραμμά σας.', - 'section_ai' => 'Credits AI', - 'section_ai_description' => 'Τα credits χρεώνονται καθώς χρησιμοποιείτε τις λειτουργίες AI. Μηδενίζονται την πρώτη κάθε μήνα.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Λογαριασμοί κοινωνικών δικτύων', - 'members' => 'Μέλη', - 'credits' => 'Credits', -]; diff --git a/lang/el/welcome.php b/lang/el/welcome.php index f4ad9557d..1e1eebece 100644 --- a/lang/el/welcome.php +++ b/lang/el/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.', 'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.', 'progress' => 'Πρόοδος καλωσορίσματος', - 'go_to_step' => 'Μετάβαση στο βήμα :step', - 'step_current' => 'Βήμα :step (τρέχον)', + 'step_of' => 'Βήμα :step από :total', + 'back' => 'Πίσω', + 'steps' => [ + 'persona' => 'Σχετικά με εσάς', + 'goals' => 'Οι στόχοι σας', + 'referral_source' => 'Πώς μας βρήκατε', + 'connect' => 'Κοινωνικά δίκτυα', + 'plan' => 'Πλάνο', + ], + 'preview' => [ + 'heading' => 'Ο workspace σας παίρνει μορφή.', + 'pending' => 'Δεν έχει επιλεγεί ακόμα', + 'networks_empty' => 'Δεν έχουν συνδεθεί δίκτυα ακόμα', + ], 'personas' => [ 'creator' => 'Δημιουργός περιεχομένου', 'freelancer' => 'Ελεύθερος επαγγελματίας', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', 'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost', - 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor', + 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT κ.λπ.', 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', @@ -38,6 +50,8 @@ 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', 'other' => 'Κάτι άλλο', ], + 'plan_title' => 'Επιλέξτε το πλάνο σας', + 'plan_description' => 'Τα δύο πλάνα έχουν τις ίδιες λειτουργίες. Διαφέρουν μόνο στον αριθμό των workspaces, και μπορείτε να αλλάξετε πλάνο όποτε θέλετε.', 'referral_source_title' => 'Πώς μας βρήκατε;', 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', 'referral_source' => [ diff --git a/lang/el/workspaces.php b/lang/el/workspaces.php index aec30cae8..1e2d31c3e 100644 --- a/lang/el/workspaces.php +++ b/lang/el/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Τρέχον', 'connections' => ':count συνδέσεις', 'posts' => ':count δημοσιεύσεις', + 'subscription_required' => 'Εγγραφείτε για να δημιουργήσετε περισσότερα workspaces.', + 'limit_reached' => 'Το πλάνο σας περιλαμβάνει ένα workspace. Κάντε αναβάθμιση για να προσθέσετε περισσότερα.', + + 'upgrade_dialog' => [ + 'title' => 'Κάντε αναβάθμιση για περισσότερα workspaces', + 'description' => 'Το πλάνο σας περιλαμβάνει ένα workspace. Αναβαθμίστε σε Workspaces για απεριόριστα workspaces — ένα ανά brand ή πελάτη.', + ], 'create' => [ 'page_title' => 'Δημιουργήστε το workspace σας', diff --git a/lang/en/accounts.php b/lang/en/accounts.php index e181f2010..c63dd1a5e 100644 --- a/lang/en/accounts.php +++ b/lang/en/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Overview of all your connected social accounts', 'connect_cta' => 'Connect', 'connect_another' => 'Connect another', + 'actions' => 'Account actions', + 'activate' => 'Turn on', + 'deactivate' => 'Turn off', + 'active' => 'Active', + 'paused' => 'Off', + 'accounts_count' => ':count account|:count accounts', + + 'variants' => [ + 'linkedin-page' => 'Page', + 'instagram-facebook' => 'via Facebook', + ], 'not_connected' => 'Not connected', 'connect' => 'Connect', diff --git a/lang/en/billing.php b/lang/en/billing.php index ed4ffa1f7..1583742f5 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Update payment', ], - 'annual_banner' => [ - 'title' => 'Get 2 months free', - 'description' => 'Switch to annual billing and pay less every month — same plan, nothing else changes.', - 'cta' => 'Upgrade to annual', - ], - 'subscribe' => [ 'billed_monthly' => 'Billed monthly', - 'billed_yearly' => 'Billed annually', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Plans', + 'description' => 'Upgrade or downgrade at any time.', + 'monthly' => 'Monthly', + 'yearly' => 'Yearly', + 'save_two_months' => '2 months free', + 'per_month' => '/month', + '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.', + 'current' => 'Current plan', + 'switch_to_yearly' => 'Switch to yearly', + 'switch_to_monthly' => 'Switch to monthly', + 'select' => 'Choose :plan', + 'upgrade' => 'Upgrade to :plan', + 'downgrade' => 'Downgrade to :plan', + 'start_first_month' => 'Start for :price', + 'per_first_month' => '/first month', + 'then_monthly' => 'Then :price/month', + 'billed_yearly_total' => 'Billed annually · :price (2 months free)', + 'socials_tagline' => 'Best for creators and small brands.', + 'workspaces_tagline' => 'Best for agencies and larger businesses.', + 'everything_included' => 'Everything included', + 'features' => [ + 'networks_all' => 'All social networks included', + 'networks_all_tooltip' => 'You can post to every one of these.', + 'accounts_unlimited' => 'Unlimited social accounts', + 'accounts_unlimited_tooltip' => 'Connect as many accounts as you want, even several from the same network. Three Instagrams, for example.', + 'calendar' => 'Calendar: month, week, and day views', + 'calendar_tooltip' => 'See your whole month at a glance: what\'s planned, scheduled, and already published. Switch to week or day when you need the detail.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Your AI assistant for writing and reviewing posts.', + 'mcp' => 'MCP: post from Claude, ChatGPT, or Grok', + 'mcp_tooltip' => 'Connect Claude, ChatGPT, or Grok to your workspace. Ask it to create and schedule posts, pull metrics, spot what performed best, and plan what comes next from your own data.', + 'repurpose' => 'Repurpose: turn one post into many', + 'repurpose_tooltip' => 'Choose a source account. Every new post you publish there is automatically republished to your other networks. You don\'t need to open TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Get metrics like impressions, reach, likes, and comments for every post and every account, all in one place.', + 'team' => 'Unlimited members', + 'team_tooltip' => 'Invite as many people as you want to your team, at no extra cost. Set what each person can do and approve posts before they go out.', ], ], 'plan' => [ - 'title' => 'Plan', - 'description' => 'Manage your subscription plan.', - 'label' => 'Plan', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'per workspace', - 'price' => 'Price', - 'month' => 'month', 'trial' => 'Trial', - 'active' => 'Active', - 'past_due' => 'Past due', 'cancelling' => 'Cancelling', 'trial_ends' => 'Trial ends', ], 'subscription' => [ - 'title' => 'Subscription', - 'description' => 'Manage your payment method, billing details, and subscription.', - 'payment_method' => 'Payment method', + 'title' => 'Payment method', + 'description' => 'Update your card or billing details on Stripe.', 'no_payment_method' => 'No payment method on file yet.', 'expires_on' => 'Expires :month/:year', - 'manage_label' => 'Subscription', 'manage_stripe' => 'Manage on Stripe', ], 'invoices' => [ 'title' => 'Invoices', 'description' => 'Download your past invoices.', - 'empty' => 'No invoices found', 'paid' => 'Paid', ], 'flash' => [ 'plan_changed' => 'You are now on the :plan plan.', - 'switched_to_yearly' => 'You\'re now on annual billing.', 'cannot_manage' => 'Only the account owner can manage billing.', - 'credits_exhausted' => 'Out of AI credits — your monthly :limit allowance has been used. Upgrade your plan or wait until next month.', + 'too_many_workspaces' => 'You have :count workspaces. This plan includes :limit — delete the extras before switching.', 'subscription_required' => 'An active subscription is required to use AI features.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Processing...', 'title' => 'Processing your subscription', 'description' => 'Please wait while we set up your account. This will only take a moment.', - 'success_title' => 'You\'re all set!', - 'success_description' => 'Your subscription is active. Redirecting you to your workspaces...', - 'cancelled_title' => 'Checkout cancelled', - 'cancelled_description' => 'Your checkout was cancelled. No charges were made.', - 'retry' => 'Try again', ], ]; diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php deleted file mode 100644 index 694edc9ad..000000000 --- a/lang/en/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Getting started', - 'welcome' => 'Welcome to TryPost, :name', - 'description' => 'Follow the steps below to see how TryPost works and publish your first post.', - 'skip_step' => 'Skip this step', - 'continue' => 'Continue to TryPost', - 'status' => [ - 'complete' => 'Complete', - 'todo' => 'To do', - 'skipped' => 'Skipped', - ], - 'mcp' => [ - 'title' => 'Connect your AI assistant', - 'description' => 'Add TryPost as an MCP server so your assistant can create and manage social posts for you.', - 'copied' => 'MCP URL copied.', - ], - 'social' => [ - 'title' => 'Connect a social account', - 'description' => 'Choose at least one network where TryPost can publish your content.', - 'connected_elsewhere' => 'You already connected an account in another workspace, so this step is done.', - ], - 'first_post' => [ - 'title' => 'Create your first post', - 'description' => 'Try this starter prompt with your connected assistant, or create the post directly in TryPost.', - 'prompt_label' => 'Sample prompt', - 'sample_prompt' => 'Create a friendly social post introducing my brand and adapt it for each connected network.', - 'copy_prompt' => 'Copy prompt', - 'copied' => 'Sample prompt copied.', - 'create_button' => 'Create your first post', - 'or' => 'or', - ], - 'ready' => [ - 'title' => 'You are ready to publish', - 'description' => 'You are set. Continue to TryPost and start planning your content.', - ], -]; diff --git a/lang/en/settings.php b/lang/en/settings.php index b4b7dc806..f13737d68 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Account', - 'usage' => 'Usage', 'billing' => 'Billing', ], 'title' => 'Account Settings', diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 4a37c1eda..7489a2133 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Account & billing', 'workspace_settings' => 'Workspace settings', 'log_out' => 'Log out', + 'help' => 'Help', 'workspace' => [ 'connections' => 'Connections', 'signatures' => 'Signatures', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analytics', 'repurposes' => 'Repurpose', - 'onboarding' => 'Getting started', - 'onboarding_hint' => 'Finish setup', 'posts' => [ 'calendar' => 'Calendar', 'all' => 'All', @@ -42,8 +41,10 @@ 'archive_all' => 'Archive all', 'no_notifications' => 'No notifications', 'support' => [ + 'chat' => 'Chat support', 'docs' => 'Documentation', 'referral' => 'Earn 30% referral', 'discord' => 'Discord community', + 'community' => 'Community', ], ]; diff --git a/lang/en/usage.php b/lang/en/usage.php deleted file mode 100644 index 3d348a0ec..000000000 --- a/lang/en/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Usage', - - 'section_account' => 'Account', - 'section_account_description' => 'Quotas and limits for your plan.', - 'section_ai' => 'AI Credits', - 'section_ai_description' => 'Credits are debited as AI features are used. They reset on the first of every month.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Social Accounts', - 'members' => 'Members', - 'credits' => 'Credits', -]; diff --git a/lang/en/welcome.php b/lang/en/welcome.php index 1664e5aba..d7ad53134 100644 --- a/lang/en/welcome.php +++ b/lang/en/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Your account owner is :name.', 'subscription_required_auto' => 'This page updates automatically — no need to refresh.', 'progress' => 'Welcome progress', - 'go_to_step' => 'Go to step :step', - 'step_current' => 'Step :step (current)', + 'step_of' => 'Step :step of :total', + 'back' => 'Back', + 'steps' => [ + 'persona' => 'About you', + 'goals' => 'Your goals', + 'referral_source' => 'How you found us', + 'connect' => 'Social networks', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Your workspace is taking shape.', + 'pending' => 'Not chosen yet', + 'networks_empty' => 'No networks connected yet', + ], 'personas' => [ 'creator' => 'Content creator', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Save time by posting everywhere at once', 'ai_content' => 'Generate posts with TryPost AI', - 'use_mcp' => 'Create posts from Claude, ChatGPT, or Cursor', + 'use_mcp' => 'Create posts from Claude, ChatGPT, etc.', 'plan_calendar' => 'Plan my posts on a calendar', 'stay_on_brand' => 'Keep every post on brand', 'grow_audience' => 'Grow my audience and engagement', @@ -38,6 +50,8 @@ 'just_exploring' => 'Just exploring for now', 'other' => 'Something else', ], + 'plan_title' => 'Choose your plan', + 'plan_description' => 'Both plans have the same features. The difference is how many workspaces you get, and you can switch plans whenever you like.', 'referral_source_title' => 'How did you find us?', 'referral_source_description' => 'This helps us understand how people discover TryPost.', 'referral_source' => [ diff --git a/lang/en/workspaces.php b/lang/en/workspaces.php index e43c5e8e9..3223cd0fc 100644 --- a/lang/en/workspaces.php +++ b/lang/en/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Current', 'connections' => ':count connections', 'posts' => ':count posts', + 'subscription_required' => 'Subscribe to create more workspaces.', + 'limit_reached' => 'Your plan includes one workspace. Upgrade to add more.', + + 'upgrade_dialog' => [ + 'title' => 'Upgrade to add workspaces', + 'description' => 'Your plan includes one workspace. Upgrade to Workspaces for unlimited workspaces — one for each brand or client.', + ], 'create' => [ 'page_title' => 'Create your workspace', diff --git a/lang/es/accounts.php b/lang/es/accounts.php index a1069f66d..3fa56202e 100644 --- a/lang/es/accounts.php +++ b/lang/es/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Resumen de todas tus cuentas sociales conectadas', 'connect_cta' => 'Conectar', 'connect_another' => 'Conectar otra', + 'actions' => 'Acciones de la cuenta', + 'activate' => 'Activar', + 'deactivate' => 'Desactivar', + 'active' => 'Activa', + 'paused' => 'Desactivada', + 'accounts_count' => ':count cuenta|:count cuentas', + + 'variants' => [ + 'linkedin-page' => 'Página', + 'instagram-facebook' => 'vía Facebook', + ], 'not_connected' => 'No conectado', 'connect' => 'Conectar', diff --git a/lang/es/billing.php b/lang/es/billing.php index deb54eb12..cc34dc86d 100644 --- a/lang/es/billing.php +++ b/lang/es/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Actualizar pago', ], - 'annual_banner' => [ - 'title' => 'Consigue 2 meses gratis', - 'description' => 'Cambia a la facturación anual y paga menos al mes — el mismo plan, sin cambios.', - 'cta' => 'Cambiar a anual', - ], - 'subscribe' => [ 'billed_monthly' => 'Facturado mensualmente', - 'billed_yearly' => 'Facturado anualmente', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Planes', + 'description' => 'Mejora o cambia de plan cuando quieras.', + 'monthly' => 'Mensual', + 'yearly' => 'Anual', + 'save_two_months' => '2 meses gratis', + 'per_month' => '/mes', + '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.', + 'current' => 'Plan actual', + 'switch_to_yearly' => 'Cambiar a anual', + 'switch_to_monthly' => 'Cambiar a mensual', + 'select' => 'Elegir :plan', + 'upgrade' => 'Mejorar a :plan', + 'downgrade' => 'Bajar a :plan', + 'start_first_month' => 'Empezar por :price', + 'per_first_month' => '/primer mes', + 'then_monthly' => 'Luego, :price/mes', + 'billed_yearly_total' => 'Facturación anual · :price (2 meses gratis)', + 'socials_tagline' => 'Ideal para creadores y marcas pequeñas.', + 'workspaces_tagline' => 'Ideal para agencias y negocios grandes.', + 'everything_included' => 'Todo incluido', + 'features' => [ + 'networks_all' => 'Todas las redes sociales incluidas', + 'networks_all_tooltip' => 'Puedes publicar en todas estas redes.', + 'accounts_unlimited' => 'Cuentas sociales ilimitadas', + 'accounts_unlimited_tooltip' => 'Conecta todas las cuentas que quieras, incluso varias de la misma red. Tres Instagrams, por ejemplo.', + 'calendar' => 'Calendario mensual, semanal y diario', + 'calendar_tooltip' => 'Ve todo tu mes de un vistazo: lo planificado, lo programado y lo ya publicado. Cambia a semana o día cuando necesites el detalle.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Tu asistente de IA para escribir y revisar posts.', + 'mcp' => 'MCP: publica desde Claude, ChatGPT o Grok', + 'mcp_tooltip' => 'Conecta Claude, ChatGPT o Grok a tu workspace. Pídele crear y programar posts, sacar métricas, ver qué funcionó mejor y planificar lo siguiente con tus propios datos.', + 'repurpose' => 'Repurpose: convierte un post en muchos', + 'repurpose_tooltip' => 'Elige una cuenta de origen. Cada publicación nueva que hagas ahí se republica automáticamente en tus otras redes. No necesitas abrir TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Obtén métricas como impresiones, alcance, likes y comentarios de cada publicación y cada cuenta, todo en un solo lugar.', + 'team' => 'Miembros ilimitados', + 'team_tooltip' => 'Invita a todas las personas que quieras a tu equipo, sin coste extra. Define qué puede hacer cada una y aprueba los posts antes de publicarlos.', ], ], 'plan' => [ - 'title' => 'Plan', - 'description' => 'Gestiona tu plan de suscripción.', - 'label' => 'Plan', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'por workspace', - 'price' => 'Precio', - 'month' => 'mes', 'trial' => 'Prueba', - 'active' => 'Activo', - 'past_due' => 'Vencido', 'cancelling' => 'Cancelando', 'trial_ends' => 'La prueba termina en', ], 'subscription' => [ - 'title' => 'Suscripción', - 'description' => 'Gestiona tu método de pago, datos de facturación y suscripción.', - 'payment_method' => 'Método de pago', + 'title' => 'Método de pago', + '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_label' => 'Suscripción', 'manage_stripe' => 'Gestionar en Stripe', ], 'invoices' => [ 'title' => 'Facturas', 'description' => 'Descarga tus facturas anteriores.', - 'empty' => 'No se encontraron facturas', 'paid' => 'Pagado', ], 'flash' => [ 'plan_changed' => 'Ahora estás en el plan :plan.', - 'switched_to_yearly' => 'Ahora tienes facturación anual.', 'cannot_manage' => 'Solo el propietario de la cuenta puede gestionar la facturación.', - 'credits_exhausted' => 'Sin créditos de IA — has usado tus :limit créditos mensuales. Mejora tu plan o espera hasta el próximo mes.', + 'too_many_workspaces' => 'Tienes :count workspaces. Este plan incluye :limit — elimina los extra antes de cambiar.', 'subscription_required' => 'Se requiere una suscripción activa para usar las funciones de IA.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Procesando...', 'title' => 'Procesando tu suscripción', 'description' => 'Espera mientras configuramos tu cuenta. Solo tomará un momento.', - 'success_title' => '¡Todo listo!', - 'success_description' => 'Tu suscripción está activa. Redirigiendo a tus workspaces...', - 'cancelled_title' => 'Pago cancelado', - 'cancelled_description' => 'Tu pago fue cancelado. No se realizaron cargos.', - 'retry' => 'Intentar de nuevo', ], ]; diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php deleted file mode 100644 index 844ba7180..000000000 --- a/lang/es/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Primeros pasos', - 'welcome' => 'Bienvenido a TryPost, :name', - 'description' => 'Sigue los pasos a continuación para ver cómo funciona TryPost y publicar tu primer post.', - 'skip_step' => 'Omitir este paso', - 'continue' => 'Continuar a TryPost', - 'status' => [ - 'complete' => 'Completado', - 'todo' => 'Pendiente', - 'skipped' => 'Omitido', - ], - 'mcp' => [ - 'title' => 'Conecta tu asistente de IA', - 'description' => 'Añade TryPost como servidor MCP para que tu asistente pueda crear y gestionar posts por ti.', - 'copied' => 'URL de MCP copiada.', - ], - 'social' => [ - 'title' => 'Conecta una cuenta social', - 'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido.', - 'connected_elsewhere' => 'Ya conectaste una cuenta en otro workspace, así que este paso está listo.', - ], - 'first_post' => [ - 'title' => 'Crea tu primer post', - 'description' => 'Prueba este prompt inicial con tu asistente conectado, o crea el post directamente en TryPost.', - 'prompt_label' => 'Prompt de ejemplo', - 'sample_prompt' => 'Crea un post social amable presentando mi marca y adáptalo para cada red conectada.', - 'copy_prompt' => 'Copiar prompt', - 'copied' => 'Prompt de ejemplo copiado.', - 'create_button' => 'Crear tu primer post', - 'or' => 'o', - ], - 'ready' => [ - 'title' => 'Listo para publicar', - 'description' => 'Todo listo. Continúa a TryPost y empieza a planificar tu contenido.', - ], -]; diff --git a/lang/es/settings.php b/lang/es/settings.php index 44310f2b3..bc225035a 100644 --- a/lang/es/settings.php +++ b/lang/es/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Cuenta', - 'usage' => 'Uso', 'billing' => 'Facturación', ], 'title' => 'Configuración de cuenta', diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index 15d5061d5..bb01ce120 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Cuenta y facturación', 'workspace_settings' => 'Configuración del workspace', 'log_out' => 'Cerrar sesión', + 'help' => 'Ayuda', 'workspace' => [ 'connections' => 'Conexiones', 'signatures' => 'Firmas', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analytics', 'repurposes' => 'Repurpose', - 'onboarding' => 'Primeros pasos', - 'onboarding_hint' => 'Termina la configuración', 'posts' => [ 'calendar' => 'Calendario', 'all' => 'Todos', @@ -42,8 +41,10 @@ 'archive_all' => 'Archivar todo', 'no_notifications' => 'Sin notificaciones', 'support' => [ + 'chat' => 'Soporte por chat', 'docs' => 'Documentación', 'referral' => 'Gana 30% de comisión', 'discord' => 'Comunidad de Discord', + 'community' => 'Comunidad', ], ]; diff --git a/lang/es/usage.php b/lang/es/usage.php deleted file mode 100644 index f71b92642..000000000 --- a/lang/es/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Uso', - - 'section_account' => 'Cuenta', - 'section_account_description' => 'Cuotas y límites de tu plan.', - 'section_ai' => 'Créditos AI', - 'section_ai_description' => 'Los créditos se debitan a medida que usas las funciones de AI. Se renuevan el día 1 de cada mes.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Cuentas Sociales', - 'members' => 'Miembros', - 'credits' => 'Créditos', -]; diff --git a/lang/es/welcome.php b/lang/es/welcome.php index f63779152..cf62e4958 100644 --- a/lang/es/welcome.php +++ b/lang/es/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'El propietario de tu cuenta es :name.', 'subscription_required_auto' => 'Esta página se actualiza automáticamente, no hace falta recargar.', 'progress' => 'Progreso de bienvenida', - 'go_to_step' => 'Ir al paso :step', - 'step_current' => 'Paso :step (actual)', + 'step_of' => 'Paso :step de :total', + 'back' => 'Volver', + 'steps' => [ + 'persona' => 'Sobre ti', + 'goals' => 'Tus objetivos', + 'referral_source' => 'Cómo nos encontraste', + 'connect' => 'Redes sociales', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Tu workspace está tomando forma.', + 'pending' => 'Aún sin elegir', + 'networks_empty' => 'Aún no hay redes conectadas', + ], 'personas' => [ 'creator' => 'Creador de contenido', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Ahorrar tiempo publicando en todas mis redes a la vez', 'ai_content' => 'Generar publicaciones con la IA de TryPost', - 'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT o Cursor', + 'use_mcp' => 'Crear publicaciones desde Claude, ChatGPT, etc.', 'plan_calendar' => 'Planificar mis publicaciones en un calendario', 'stay_on_brand' => 'Mantener la coherencia de mi marca', 'grow_audience' => 'Hacer crecer mi audiencia y engagement', @@ -38,6 +50,8 @@ 'just_exploring' => 'Solo estoy explorando por ahora', 'other' => 'Otra cosa', ], + 'plan_title' => 'Elige tu plan', + 'plan_description' => 'Los dos planes tienen las mismas funciones. Lo que cambia es el número de workspaces, y puedes cambiar de plan cuando quieras.', 'referral_source_title' => '¿Cómo nos encontraste?', 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', 'referral_source' => [ diff --git a/lang/es/workspaces.php b/lang/es/workspaces.php index 78789e5fd..40bfdb381 100644 --- a/lang/es/workspaces.php +++ b/lang/es/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Actual', 'connections' => ':count conexiones', 'posts' => ':count posts', + 'subscription_required' => 'Suscríbete para crear más workspaces.', + 'limit_reached' => 'Tu plan incluye un workspace. Mejora tu plan para añadir más.', + + 'upgrade_dialog' => [ + 'title' => 'Mejora tu plan para añadir workspaces', + 'description' => 'Tu plan incluye un workspace. Mejora a Workspaces para tener workspaces ilimitados: uno por cada marca o cliente.', + ], 'create' => [ 'page_title' => 'Crea tu workspace', diff --git a/lang/fr/accounts.php b/lang/fr/accounts.php index b1581a078..70da21867 100644 --- a/lang/fr/accounts.php +++ b/lang/fr/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Vue d\'ensemble de tous vos comptes sociaux connectés', 'connect_cta' => 'Connecter', 'connect_another' => 'Connecter un autre', + 'actions' => 'Actions du compte', + 'activate' => 'Activer', + 'deactivate' => 'Désactiver', + 'active' => 'Actif', + 'paused' => 'Désactivé', + 'accounts_count' => ':count compte|:count comptes', + + 'variants' => [ + 'linkedin-page' => 'Page', + 'instagram-facebook' => 'via Facebook', + ], 'not_connected' => 'Non connecté', 'connect' => 'Connecter', diff --git a/lang/fr/billing.php b/lang/fr/billing.php index d423e220c..9a24b98e9 100644 --- a/lang/fr/billing.php +++ b/lang/fr/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Mettre à jour le paiement', ], - 'annual_banner' => [ - 'title' => 'Obtenez 2 mois gratuits', - 'description' => 'Passez à la facturation annuelle et payez moins chaque mois — même forfait, rien d\'autre ne change.', - 'cta' => 'Passer à l\'annuel', - ], - 'subscribe' => [ 'billed_monthly' => 'Facturé mensuellement', - 'billed_yearly' => 'Facturé annuellement', 'prices' => [ - 'workspace' => ['monthly' => '12 $', 'yearly_per_month' => '10 $', 'yearly' => '120 $'], + 'first_month' => '1 $', + 'socials' => ['monthly' => '19 $', 'yearly_per_month' => '15,83 $', 'yearly' => '190 $'], + 'workspaces' => ['monthly' => '99 $', 'yearly_per_month' => '82,50 $', 'yearly' => '990 $'], + ], + ], + + 'plans' => [ + 'title' => 'Offres', + 'description' => 'Passez à une offre supérieure ou inférieure à tout moment.', + 'monthly' => 'Mensuel', + 'yearly' => 'Annuel', + 'save_two_months' => '2 mois offerts', + 'per_month' => '/mois', + '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.', + 'current' => 'Offre actuelle', + 'switch_to_yearly' => 'Passer à l’annuel', + 'switch_to_monthly' => 'Passer au mensuel', + 'select' => 'Choisir :plan', + 'upgrade' => 'Passer à :plan', + 'downgrade' => 'Revenir à :plan', + 'start_first_month' => 'Commencer pour :price', + 'per_first_month' => '/premier mois', + 'then_monthly' => 'Puis :price/mois', + 'billed_yearly_total' => 'Facturé annuellement · :price (2 mois offerts)', + 'socials_tagline' => 'Idéal pour les créateurs et petites marques.', + 'workspaces_tagline' => 'Idéal pour les agences et grandes entreprises.', + 'everything_included' => 'Tout est inclus', + 'features' => [ + 'networks_all' => 'Tous les réseaux sociaux inclus', + 'networks_all_tooltip' => 'Vous pouvez publier sur tous ces réseaux.', + 'accounts_unlimited' => 'Comptes sociaux illimités', + 'accounts_unlimited_tooltip' => 'Connectez autant de comptes que vous voulez, même plusieurs du même réseau. Trois Instagram, par exemple.', + 'calendar' => 'Calendrier mensuel, hebdomadaire et quotidien', + 'calendar_tooltip' => 'Voyez tout votre mois d\'un coup d\'œil : ce qui est prévu, planifié et déjà publié. Passez à la semaine ou au jour quand il vous faut le détail.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Votre assistant IA pour rédiger et relire vos posts.', + 'mcp' => 'MCP : publiez depuis Claude, ChatGPT ou Grok', + 'mcp_tooltip' => 'Connectez Claude, ChatGPT ou Grok à votre workspace. Demandez-lui de créer et planifier des posts, de sortir les métriques, de repérer ce qui a le mieux marché et de préparer la suite à partir de vos données.', + 'repurpose' => 'Repurpose : transformez un post en plusieurs', + 'repurpose_tooltip' => 'Choisissez un compte source. Chaque nouveau post que vous y publiez est automatiquement republié sur vos autres réseaux. Pas besoin d\'ouvrir TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Obtenez des métriques comme les impressions, la portée, les likes et les commentaires pour chaque post et chaque compte, tout au même endroit.', + 'team' => 'Membres illimités', + 'team_tooltip' => 'Invitez autant de personnes que vous voulez dans votre équipe, sans frais supplémentaires. Définissez ce que chacun peut faire et validez les posts avant publication.', ], ], 'plan' => [ - 'title' => 'Forfait', - 'description' => 'Gérez votre forfait d\'abonnement.', - 'label' => 'Forfait', - 'workspaces' => '{1}:count espace de travail|[2,*]:count espaces de travail', - 'per_workspace' => 'par espace de travail', - 'price' => 'Prix', - 'month' => 'mois', 'trial' => 'Essai', - 'active' => 'Actif', - 'past_due' => 'En retard', 'cancelling' => 'Annulation en cours', 'trial_ends' => 'Fin de l\'essai', ], 'subscription' => [ - 'title' => 'Abonnement', - 'description' => 'Gérez votre moyen de paiement, vos informations de facturation et votre abonnement.', - 'payment_method' => 'Moyen de paiement', + 'title' => 'Moyen de paiement', + '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_label' => 'Abonnement', 'manage_stripe' => 'Gérer sur Stripe', ], 'invoices' => [ 'title' => 'Factures', 'description' => 'Téléchargez vos factures passées.', - 'empty' => 'Aucune facture trouvée', 'paid' => 'Payée', ], 'flash' => [ 'plan_changed' => 'Vous êtes maintenant sur le forfait :plan.', - 'switched_to_yearly' => 'Vous êtes maintenant en facturation annuelle.', 'cannot_manage' => 'Seul le propriétaire du compte peut gérer la facturation.', - 'credits_exhausted' => 'Crédits IA épuisés — votre quota mensuel de :limit a été utilisé. Améliorez votre forfait ou attendez le mois prochain.', + 'too_many_workspaces' => 'Vous avez :count espaces de travail. Cette offre en inclut :limit — supprimez les extras avant de changer.', 'subscription_required' => 'Un abonnement actif est requis pour utiliser les fonctionnalités d\'IA.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Traitement...', 'title' => 'Traitement de votre abonnement', 'description' => 'Veuillez patienter pendant que nous configurons votre compte. Cela ne prendra qu\'un instant.', - 'success_title' => 'Tout est prêt !', - 'success_description' => 'Votre abonnement est actif. Redirection vers vos espaces de travail...', - 'cancelled_title' => 'Paiement annulé', - 'cancelled_description' => 'Votre paiement a été annulé. Aucun montant n\'a été débité.', - 'retry' => 'Réessayer', ], ]; diff --git a/lang/fr/onboarding.php b/lang/fr/onboarding.php deleted file mode 100644 index 1455dcdc9..000000000 --- a/lang/fr/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Premiers pas', - 'welcome' => 'Bienvenue sur TryPost, :name', - 'description' => 'Suivez les étapes ci-dessous pour découvrir comment TryPost fonctionne et publier votre premier post.', - 'skip_step' => 'Ignorer cette étape', - 'continue' => 'Continuer vers TryPost', - 'status' => [ - 'complete' => 'Terminé', - 'todo' => 'À faire', - 'skipped' => 'Ignorée', - ], - 'mcp' => [ - 'title' => 'Connectez votre assistant IA', - 'description' => 'Ajoutez TryPost comme serveur MCP pour que votre assistant puisse créer et gérer vos posts sociaux.', - 'copied' => 'URL MCP copiée.', - ], - 'social' => [ - 'title' => 'Connectez un compte social', - 'description' => 'Choisissez au moins un réseau où TryPost pourra publier votre contenu.', - 'connected_elsewhere' => 'Vous avez déjà connecté un compte dans un autre workspace, cette étape est donc terminée.', - ], - 'first_post' => [ - 'title' => 'Créez votre premier post', - 'description' => 'Essayez ce prompt de démarrage avec votre assistant connecté, ou créez le post directement dans TryPost.', - 'prompt_label' => 'Prompt d’exemple', - 'sample_prompt' => 'Crée un post social amical présentant ma marque et adapte-le pour chaque réseau connecté.', - 'copy_prompt' => 'Copier le prompt', - 'copied' => 'Prompt d’exemple copié.', - 'create_button' => 'Créer votre premier post', - 'or' => 'ou', - ], - 'ready' => [ - 'title' => 'Vous êtes prêt à publier', - 'description' => 'Tout est bon. Continuez vers TryPost et commencez à planifier votre contenu.', - ], -]; diff --git a/lang/fr/settings.php b/lang/fr/settings.php index 2cede7c15..74ac0dc39 100644 --- a/lang/fr/settings.php +++ b/lang/fr/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Compte', - 'usage' => 'Utilisation', 'billing' => 'Facturation', ], 'title' => 'Paramètres du compte', diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index ba4c6f791..dcef0e3c4 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Compte et facturation', 'workspace_settings' => 'Paramètres de l\'espace de travail', 'log_out' => 'Se déconnecter', + 'help' => 'Aide', 'workspace' => [ 'connections' => 'Connexions', 'signatures' => 'Signatures', @@ -27,8 +28,6 @@ ], 'analytics' => 'Statistiques', 'repurposes' => 'Repurpose', - 'onboarding' => 'Premiers pas', - 'onboarding_hint' => 'Terminer la configuration', 'posts' => [ 'calendar' => 'Calendrier', 'all' => 'Toutes', @@ -42,8 +41,10 @@ 'archive_all' => 'Tout archiver', 'no_notifications' => 'Aucune notification', 'support' => [ + 'chat' => 'Support par chat', 'docs' => 'Documentation', 'referral' => 'Gagnez 30 % de parrainage', 'discord' => 'Communauté Discord', + 'community' => 'Communauté', ], ]; diff --git a/lang/fr/usage.php b/lang/fr/usage.php deleted file mode 100644 index 8444698df..000000000 --- a/lang/fr/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Utilisation', - - 'section_account' => 'Compte', - 'section_account_description' => 'Quotas et limites de votre forfait.', - 'section_ai' => 'Crédits IA', - 'section_ai_description' => 'Les crédits sont débités à mesure que vous utilisez les fonctionnalités d\'IA. Ils sont réinitialisés le premier de chaque mois.', - - 'workspaces' => 'Espaces de travail', - 'social_accounts' => 'Comptes sociaux', - 'members' => 'Membres', - 'credits' => 'Crédits', -]; diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php index bbc8fddd5..424552d90 100644 --- a/lang/fr/welcome.php +++ b/lang/fr/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Le propriétaire de votre compte est :name.', 'subscription_required_auto' => 'Cette page se met à jour automatiquement — inutile de la recharger.', 'progress' => 'Progression d’accueil', - 'go_to_step' => 'Aller à l’étape :step', - 'step_current' => 'Étape :step (actuelle)', + 'step_of' => 'Étape :step sur :total', + 'back' => 'Retour', + 'steps' => [ + 'persona' => 'À propos de vous', + 'goals' => 'Vos objectifs', + 'referral_source' => 'Comment vous nous avez trouvés', + 'connect' => 'Réseaux sociaux', + 'plan' => 'Forfait', + ], + 'preview' => [ + 'heading' => 'Votre workspace prend forme.', + 'pending' => 'Pas encore choisi', + 'networks_empty' => 'Aucun réseau connecté pour l’instant', + ], 'personas' => [ 'creator' => 'Créateur de contenu', 'freelancer' => 'Freelance', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Gagner du temps en publiant partout à la fois', 'ai_content' => 'Générer des publications avec l\'IA TryPost', - 'use_mcp' => 'Créer des publications depuis Claude, ChatGPT ou Cursor', + 'use_mcp' => 'Créer des publications depuis Claude, ChatGPT, etc.', 'plan_calendar' => 'Planifier mes publications sur un calendrier', 'stay_on_brand' => 'Garder chaque publication fidèle à ma marque', 'grow_audience' => 'Développer mon audience et mon engagement', @@ -38,6 +50,8 @@ 'just_exploring' => 'Je découvre pour l\'instant', 'other' => 'Autre chose', ], + 'plan_title' => 'Choisissez votre forfait', + 'plan_description' => 'Les deux formules ont les mêmes fonctionnalités. Seul le nombre de workspaces change, et vous pouvez changer de formule quand vous voulez.', 'referral_source_title' => 'Comment nous avez-vous connus ?', 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', 'referral_source' => [ diff --git a/lang/fr/workspaces.php b/lang/fr/workspaces.php index 1bff98114..6322b7cc7 100644 --- a/lang/fr/workspaces.php +++ b/lang/fr/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Actuel', 'connections' => ':count connexions', 'posts' => ':count publications', + 'subscription_required' => 'Abonnez-vous pour créer plus d\'espaces de travail.', + 'limit_reached' => 'Votre offre inclut un espace de travail. Passez à une offre supérieure pour en ajouter.', + + 'upgrade_dialog' => [ + 'title' => 'Passez à un forfait supérieur pour ajouter des workspaces', + 'description' => 'Votre forfait inclut un workspace. Passez à Workspaces pour des workspaces illimités — un par marque ou client.', + ], 'create' => [ 'page_title' => 'Créer votre espace de travail', diff --git a/lang/it/accounts.php b/lang/it/accounts.php index 525ebc5eb..bb100bc11 100644 --- a/lang/it/accounts.php +++ b/lang/it/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Panoramica di tutti i tuoi account social collegati', 'connect_cta' => 'Collega', 'connect_another' => 'Collega un altro', + 'actions' => 'Azioni account', + 'activate' => 'Attiva', + 'deactivate' => 'Disattiva', + 'active' => 'Attivo', + 'paused' => 'Disattivato', + 'accounts_count' => ':count account|:count account', + + 'variants' => [ + 'linkedin-page' => 'Pagina', + 'instagram-facebook' => 'via Facebook', + ], 'not_connected' => 'Non collegato', 'connect' => 'Collega', diff --git a/lang/it/billing.php b/lang/it/billing.php index 2af61f74e..558ac6d0e 100644 --- a/lang/it/billing.php +++ b/lang/it/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Aggiorna pagamento', ], - 'annual_banner' => [ - 'title' => 'Ottieni 2 mesi gratis', - 'description' => 'Passa alla fatturazione annuale e paga meno ogni mese: stesso piano, nient\'altro cambia.', - 'cta' => 'Passa all\'annuale', - ], - 'subscribe' => [ 'billed_monthly' => 'Fatturazione mensile', - 'billed_yearly' => 'Fatturazione annuale', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Piani', + 'description' => 'Fai upgrade o downgrade in qualsiasi momento.', + 'monthly' => 'Mensile', + 'yearly' => 'Annuale', + 'save_two_months' => '2 mesi gratis', + 'per_month' => '/mese', + '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.', + 'current' => 'Piano attuale', + 'switch_to_yearly' => 'Passa all’annuale', + 'switch_to_monthly' => 'Passa al mensile', + 'select' => 'Scegli :plan', + 'upgrade' => 'Fai upgrade a :plan', + 'downgrade' => 'Fai downgrade a :plan', + 'start_first_month' => 'Inizia a :price', + 'per_first_month' => '/primo mese', + 'then_monthly' => 'Poi :price/mese', + 'billed_yearly_total' => 'Fatturato annualmente · :price (2 mesi gratis)', + 'socials_tagline' => 'Ideale per creator e piccole marche.', + 'workspaces_tagline' => 'Ideale per agenzie e grandi attività.', + 'everything_included' => 'Tutto incluso', + 'features' => [ + 'networks_all' => 'Tutti i social network inclusi', + 'networks_all_tooltip' => 'Puoi pubblicare su tutti questi social.', + 'accounts_unlimited' => 'Account social illimitati', + 'accounts_unlimited_tooltip' => 'Collega tutti gli account che vuoi, anche più di uno dello stesso social. Tre Instagram, per esempio.', + 'calendar' => 'Calendario mensile, settimanale e giornaliero', + 'calendar_tooltip' => 'Guarda tutto il mese in un colpo d\'occhio: cosa è pianificato, programmato e già pubblicato. Passa a settimana o giorno quando ti serve il dettaglio.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Il tuo assistente IA per scrivere e rivedere i post.', + 'mcp' => 'MCP: pubblica da Claude, ChatGPT o Grok', + 'mcp_tooltip' => 'Collega Claude, ChatGPT o Grok al tuo workspace. Chiedigli di creare e programmare post, recuperare le metriche, capire cosa ha funzionato meglio e pianificare il prossimo contenuto dai tuoi dati.', + 'repurpose' => 'Repurpose: trasforma un post in tanti', + 'repurpose_tooltip' => 'Scegli un account di origine. Ogni nuovo post che pubblichi lì viene ripubblicato automaticamente sugli altri tuoi social. Non devi aprire TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Ottieni metriche come impression, copertura, like e commenti per ogni post e ogni account, tutto in un unico posto.', + 'team' => 'Membri illimitati', + 'team_tooltip' => 'Invita nel team tutte le persone che vuoi, senza costi extra. Decidi cosa può fare ciascuna e approva i post prima della pubblicazione.', ], ], 'plan' => [ - 'title' => 'Piano', - 'description' => 'Gestisci il tuo piano di abbonamento.', - 'label' => 'Piano', - 'workspaces' => '{1}:count workspace|[2,*]:count workspace', - 'per_workspace' => 'per workspace', - 'price' => 'Prezzo', - 'month' => 'mese', 'trial' => 'Prova', - 'active' => 'Attivo', - 'past_due' => 'Scaduto', 'cancelling' => 'In cancellazione', 'trial_ends' => 'La prova termina', ], 'subscription' => [ - 'title' => 'Abbonamento', - 'description' => 'Gestisci il tuo metodo di pagamento, i dati di fatturazione e l\'abbonamento.', - 'payment_method' => 'Metodo di pagamento', + 'title' => 'Metodo di pagamento', + '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_label' => 'Abbonamento', 'manage_stripe' => 'Gestisci su Stripe', ], 'invoices' => [ 'title' => 'Fatture', 'description' => 'Scarica le tue fatture passate.', - 'empty' => 'Nessuna fattura trovata', 'paid' => 'Pagata', ], 'flash' => [ 'plan_changed' => 'Ora sei sul piano :plan.', - 'switched_to_yearly' => 'Ora hai la fatturazione annuale.', 'cannot_manage' => 'Solo il proprietario dell\'account può gestire la fatturazione.', - 'credits_exhausted' => 'Crediti IA esauriti: la tua quota mensile di :limit è stata utilizzata. Aggiorna il tuo piano o attendi il mese prossimo.', + 'too_many_workspaces' => 'Hai :count workspace. Questo piano ne include :limit — elimina quelli extra prima di cambiare.', 'subscription_required' => 'È richiesto un abbonamento attivo per usare le funzioni IA.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Elaborazione...', 'title' => 'Elaborazione del tuo abbonamento', 'description' => 'Attendi mentre configuriamo il tuo account. Ci vorrà solo un momento.', - 'success_title' => 'Tutto pronto!', - 'success_description' => 'Il tuo abbonamento è attivo. Ti stiamo reindirizzando ai tuoi workspace...', - 'cancelled_title' => 'Pagamento annullato', - 'cancelled_description' => 'Il tuo pagamento è stato annullato. Non è stato effettuato alcun addebito.', - 'retry' => 'Riprova', ], ]; diff --git a/lang/it/onboarding.php b/lang/it/onboarding.php deleted file mode 100644 index ac667df84..000000000 --- a/lang/it/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Primi passi', - 'welcome' => 'Benvenuto su TryPost, :name', - 'description' => 'Segui i passaggi qui sotto per vedere come funziona TryPost e pubblicare il tuo primo post.', - 'skip_step' => 'Salta questo passaggio', - 'continue' => 'Continua su TryPost', - 'status' => [ - 'complete' => 'Completato', - 'todo' => 'Da fare', - 'skipped' => 'Saltato', - ], - 'mcp' => [ - 'title' => 'Collega il tuo assistente IA', - 'description' => 'Aggiungi TryPost come server MCP così il tuo assistente può creare e gestire i post social per te.', - 'copied' => 'URL MCP copiato.', - ], - 'social' => [ - 'title' => 'Collega un account social', - 'description' => 'Scegli almeno una rete dove TryPost possa pubblicare i tuoi contenuti.', - 'connected_elsewhere' => 'Hai già collegato un account in un altro workspace, quindi questo passaggio è completato.', - ], - 'first_post' => [ - 'title' => 'Crea il tuo primo post', - 'description' => 'Prova questo prompt iniziale con il tuo assistente collegato, oppure crea il post direttamente in TryPost.', - 'prompt_label' => 'Prompt di esempio', - 'sample_prompt' => 'Crea un post social amichevole per presentare il mio brand e adattalo a ogni rete collegata.', - 'copy_prompt' => 'Copia prompt', - 'copied' => 'Prompt di esempio copiato.', - 'create_button' => 'Crea il tuo primo post', - 'or' => 'oppure', - ], - 'ready' => [ - 'title' => 'Sei pronto a pubblicare', - 'description' => 'Tutto a posto. Continua su TryPost e inizia a pianificare i tuoi contenuti.', - ], -]; diff --git a/lang/it/settings.php b/lang/it/settings.php index 08f8e3417..27b4a4560 100644 --- a/lang/it/settings.php +++ b/lang/it/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Account', - 'usage' => 'Utilizzo', 'billing' => 'Fatturazione', ], 'title' => 'Impostazioni dell\'account', diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index ce611fe30..612da3cb0 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Account e fatturazione', 'workspace_settings' => 'Impostazioni workspace', 'log_out' => 'Esci', + 'help' => 'Aiuto', 'workspace' => [ 'connections' => 'Connessioni', 'signatures' => 'Firme', @@ -27,8 +28,6 @@ ], 'analytics' => 'Statistiche', 'repurposes' => 'Repurpose', - 'onboarding' => 'Primi passi', - 'onboarding_hint' => 'Completa la configurazione', 'posts' => [ 'calendar' => 'Calendario', 'all' => 'Tutti', @@ -42,8 +41,10 @@ 'archive_all' => 'Archivia tutte', 'no_notifications' => 'Nessuna notifica', 'support' => [ + 'chat' => 'Supporto via chat', 'docs' => 'Documentazione', 'referral' => 'Guadagna il 30% di referral', 'discord' => 'Community Discord', + 'community' => 'Community', ], ]; diff --git a/lang/it/usage.php b/lang/it/usage.php deleted file mode 100644 index 0a03ffeb8..000000000 --- a/lang/it/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Utilizzo', - - 'section_account' => 'Account', - 'section_account_description' => 'Quote e limiti del tuo piano.', - 'section_ai' => 'Crediti IA', - 'section_ai_description' => 'I crediti vengono scalati man mano che usi le funzioni IA. Si azzerano il primo di ogni mese.', - - 'workspaces' => 'Workspace', - 'social_accounts' => 'Account social', - 'members' => 'Membri', - 'credits' => 'Crediti', -]; diff --git a/lang/it/welcome.php b/lang/it/welcome.php index 8f34df080..2682e5491 100644 --- a/lang/it/welcome.php +++ b/lang/it/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Il proprietario del tuo account è :name.', 'subscription_required_auto' => 'Questa pagina si aggiorna automaticamente: non serve ricaricarla.', 'progress' => 'Progresso di benvenuto', - 'go_to_step' => 'Vai al passaggio :step', - 'step_current' => 'Passaggio :step (attuale)', + 'step_of' => 'Passaggio :step di :total', + 'back' => 'Indietro', + 'steps' => [ + 'persona' => 'Su di te', + 'goals' => 'I tuoi obiettivi', + 'referral_source' => 'Come ci hai trovato', + 'connect' => 'Social network', + 'plan' => 'Piano', + ], + 'preview' => [ + 'heading' => 'Il tuo workspace sta prendendo forma.', + 'pending' => 'Non ancora scelto', + 'networks_empty' => 'Nessun social connesso per ora', + ], 'personas' => [ 'creator' => 'Creatore di contenuti', 'freelancer' => 'Freelance', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Risparmiare tempo pubblicando ovunque in una volta', 'ai_content' => 'Generare post con l\'IA di TryPost', - 'use_mcp' => 'Creare post da Claude, ChatGPT o Cursor', + 'use_mcp' => 'Creare post da Claude, ChatGPT, ecc.', 'plan_calendar' => 'Pianificare i miei post su un calendario', 'stay_on_brand' => 'Mantenere ogni post in linea con il brand', 'grow_audience' => 'Far crescere il mio pubblico e il coinvolgimento', @@ -38,6 +50,8 @@ 'just_exploring' => 'Sto solo dando un\'occhiata', 'other' => 'Qualcos\'altro', ], + 'plan_title' => 'Scegli il tuo piano', + 'plan_description' => 'I due piani hanno le stesse funzionalità. Cambia solo il numero di workspace, e puoi cambiare piano quando vuoi.', 'referral_source_title' => 'Come ci hai trovato?', 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', 'referral_source' => [ diff --git a/lang/it/workspaces.php b/lang/it/workspaces.php index 15c2c6597..b1066902c 100644 --- a/lang/it/workspaces.php +++ b/lang/it/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Attuale', 'connections' => ':count connessioni', 'posts' => ':count post', + 'subscription_required' => 'Abbonati per creare altri workspace.', + 'limit_reached' => 'Il tuo piano include un workspace. Fai l\'upgrade per aggiungerne altri.', + + 'upgrade_dialog' => [ + 'title' => 'Esegui l’upgrade per aggiungere workspace', + 'description' => 'Il tuo piano include un workspace. Passa a Workspaces per workspace illimitati — uno per ogni brand o cliente.', + ], 'create' => [ 'page_title' => 'Crea il tuo workspace', diff --git a/lang/ja/accounts.php b/lang/ja/accounts.php index b0ff0cf5a..37e8c6b2b 100644 --- a/lang/ja/accounts.php +++ b/lang/ja/accounts.php @@ -6,6 +6,17 @@ 'description' => '接続済みのソーシャルアカウントの一覧', 'connect_cta' => '接続', 'connect_another' => '別のアカウントを接続', + 'actions' => 'アカウント操作', + 'activate' => '有効にする', + 'deactivate' => '無効にする', + 'active' => '有効', + 'paused' => '無効', + 'accounts_count' => ':count 件のアカウント', + + 'variants' => [ + 'linkedin-page' => 'ページ', + 'instagram-facebook' => 'Facebook経由', + ], 'not_connected' => '未接続', 'connect' => '接続', diff --git a/lang/ja/billing.php b/lang/ja/billing.php index 3b73b4ce2..75785dfd9 100644 --- a/lang/ja/billing.php +++ b/lang/ja/billing.php @@ -9,57 +9,82 @@ 'cta' => 'お支払いを更新', ], - 'annual_banner' => [ - 'title' => '2 か月分無料', - 'description' => '年払いに切り替えると、毎月のお支払いが安くなります — プランは同じで、他は何も変わりません。', - 'cta' => '年払いにアップグレード', - ], - 'subscribe' => [ 'billed_monthly' => '月払い', - 'billed_yearly' => '年払い', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], ], - 'plan' => [ + 'plans' => [ 'title' => 'プラン', - 'description' => 'サブスクリプションプランを管理します。', - 'label' => 'プラン', - 'workspaces' => '{1}:count 個のワークスペース|[2,*]:count 個のワークスペース', - 'per_workspace' => 'ワークスペースあたり', - 'price' => '料金', - 'month' => '月', + 'description' => 'いつでもアップグレードまたはダウングレードできます。', + 'monthly' => '月額', + 'yearly' => '年額', + 'save_two_months' => '2か月分無料', + 'per_month' => '/月', + 'workspaces_one' => 'ワークスペース 1 つ', + 'workspaces_unlimited' => '無制限のワークスペース', + 'workspaces_tooltip' => 'ワークスペースは 1 つのブランドや顧客ごとの独立した領域です。SNS アカウント、署名、ラベル、アナリティクス、メンバー権限、MCP 接続をそれぞれ個別に持ちます。', + 'current' => '現在のプラン', + 'switch_to_yearly' => '年払いに切り替え', + 'switch_to_monthly' => '月払いに切り替え', + 'select' => ':plan を選ぶ', + 'upgrade' => ':plan にアップグレード', + 'downgrade' => ':plan にダウングレード', + 'start_first_month' => ':priceで始める', + 'per_first_month' => '/初月', + 'then_monthly' => 'その後は:price/月', + 'billed_yearly_total' => '年払い · :price(2か月分無料)', + 'socials_tagline' => 'クリエイターや小規模ブランド向け。', + 'workspaces_tagline' => '代理店や大規模ビジネス向け。', + 'everything_included' => 'すべて含まれます', + 'features' => [ + 'networks_all' => 'すべてのSNSに対応', + 'networks_all_tooltip' => 'これらすべてのSNSに投稿できます。', + 'accounts_unlimited' => 'ソーシャルアカウント数無制限', + 'accounts_unlimited_tooltip' => 'アカウントはいくつでも接続できます。同じSNSの複数アカウントも可能です。例えばInstagramを3つ。', + 'calendar' => 'カレンダー:月・週・日表示', + 'calendar_tooltip' => '1か月をひと目で把握:予定中、予約済み、公開済みの投稿がすべて見えます。詳しく見たいときは週や日に切り替え。', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => '投稿の作成と見直しを手伝うAIアシスタント。', + 'mcp' => 'MCP:Claude、ChatGPT、Grokから投稿', + 'mcp_tooltip' => 'Claude、ChatGPT、Grok をワークスペースに接続。投稿の作成と予約、指標の取得、成果の高い投稿の把握、自分のデータに基づく次のコンテンツの計画まで頼めます。', + 'repurpose' => 'Repurpose:1本の投稿を複数に展開', + 'repurpose_tooltip' => '元になるアカウントを選びます。そこに新しく投稿するたびに、他のSNSへ自動で再投稿されます。TryPostを開く必要はありません。', + 'analytics' => 'アナリティクス', + 'analytics_tooltip' => 'インプレッション、リーチ、いいね、コメントなどの指標を、投稿ごと・アカウントごとに1か所で確認できます。', + 'team' => 'メンバー数無制限', + 'team_tooltip' => '追加料金なしで、チームに何人でも招待できます。各メンバーができることを設定し、公開前に投稿を承認できます。', + ], + ], + + 'plan' => [ 'trial' => 'トライアル', - 'active' => '有効', - 'past_due' => '延滞中', 'cancelling' => '解約手続き中', 'trial_ends' => 'トライアル終了', ], 'subscription' => [ - 'title' => 'サブスクリプション', - 'description' => 'お支払い方法、請求情報、サブスクリプションを管理します。', - 'payment_method' => 'お支払い方法', + 'title' => 'お支払い方法', + 'description' => 'カードや請求情報は Stripe で更新できます。', 'no_payment_method' => 'まだ登録されたお支払い方法がありません。', 'expires_on' => '有効期限 :month/:year', - 'manage_label' => 'サブスクリプション', 'manage_stripe' => 'Stripe で管理', ], 'invoices' => [ 'title' => '請求書', 'description' => '過去の請求書をダウンロードできます。', - 'empty' => '請求書が見つかりません', 'paid' => '支払い済み', ], 'flash' => [ 'plan_changed' => ':plan プランに変更されました。', - 'switched_to_yearly' => '年払いに変更されました。', 'cannot_manage' => 'お支払いを管理できるのはアカウントのオーナーのみです。', - 'credits_exhausted' => 'AI クレジットが不足しています — 今月の :limit の割り当てをすべて使い切りました。プランをアップグレードするか、来月までお待ちください。', + 'too_many_workspaces' => 'ワークスペースが :count あります。このプランは :limit までです。切り替える前に余分なものを削除してください。', 'subscription_required' => 'AI 機能を使用するには有効なサブスクリプションが必要です。', ], @@ -67,10 +92,5 @@ 'page_title' => '処理中...', 'title' => 'サブスクリプションを処理しています', 'description' => 'アカウントを設定していますので、しばらくお待ちください。すぐに完了します。', - 'success_title' => '準備が整いました!', - 'success_description' => 'サブスクリプションが有効になりました。ワークスペースにリダイレクトしています...', - 'cancelled_title' => 'チェックアウトがキャンセルされました', - 'cancelled_description' => 'チェックアウトはキャンセルされました。料金は請求されていません。', - 'retry' => 'もう一度試す', ], ]; diff --git a/lang/ja/onboarding.php b/lang/ja/onboarding.php deleted file mode 100644 index 4ed5065e0..000000000 --- a/lang/ja/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'はじめに', - 'welcome' => 'TryPostへようこそ、:nameさん', - 'description' => '以下のステップでTryPostの使い方を確認し、最初の投稿を公開しましょう。', - 'skip_step' => 'このステップをスキップ', - 'continue' => 'TryPostへ進む', - 'status' => [ - 'complete' => '完了', - 'todo' => '未完了', - 'skipped' => 'スキップ済み', - ], - 'mcp' => [ - 'title' => 'AIアシスタントを接続', - 'description' => 'TryPostをMCPサーバーとして追加すると、アシスタントがSNS投稿の作成・管理を行えます。', - 'copied' => 'MCP URLをコピーしました。', - ], - 'social' => [ - 'title' => 'SNSアカウントを接続', - 'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選びます。', - 'connected_elsewhere' => '別のワークスペースでアカウントを接続済みなので、このステップは完了しています。', - ], - 'first_post' => [ - 'title' => '最初の投稿を作成', - 'description' => '接続したアシスタントでこのスタータープロンプトを試すか、TryPostで直接投稿を作成します。', - 'prompt_label' => 'サンプルプロンプト', - 'sample_prompt' => 'ブランドを紹介する親しみやすいSNS投稿を作成し、接続済みの各ネットワーク向けに最適化してください。', - 'copy_prompt' => 'プロンプトをコピー', - 'copied' => 'サンプルプロンプトをコピーしました。', - 'create_button' => '最初の投稿を作成', - 'or' => 'または', - ], - 'ready' => [ - 'title' => '公開の準備ができました', - 'description' => '設定完了です。TryPostへ進み、コンテンツの計画を始めましょう。', - ], -]; diff --git a/lang/ja/settings.php b/lang/ja/settings.php index 94e76beb7..5fd029838 100644 --- a/lang/ja/settings.php +++ b/lang/ja/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'アカウント', - 'usage' => '使用状況', 'billing' => 'お支払い', ], 'title' => 'アカウント設定', diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index 2613a93e5..5d17a38df 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'アカウントと請求', 'workspace_settings' => 'ワークスペース設定', 'log_out' => 'ログアウト', + 'help' => 'ヘルプ', 'workspace' => [ 'connections' => '接続', 'signatures' => '署名', @@ -27,8 +28,6 @@ ], 'analytics' => 'アナリティクス', 'repurposes' => 'Repurpose', - 'onboarding' => 'はじめに', - 'onboarding_hint' => 'セットアップを完了', 'posts' => [ 'calendar' => 'カレンダー', 'all' => 'すべて', @@ -42,8 +41,10 @@ 'archive_all' => 'すべてアーカイブ', 'no_notifications' => '通知はありません', 'support' => [ + 'chat' => 'チャットサポート', 'docs' => 'ドキュメント', 'referral' => '30% の紹介報酬を獲得', 'discord' => 'Discord コミュニティ', + 'community' => 'コミュニティ', ], ]; diff --git a/lang/ja/usage.php b/lang/ja/usage.php deleted file mode 100644 index 07b78ab8a..000000000 --- a/lang/ja/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - '使用状況', - - 'section_account' => 'アカウント', - 'section_account_description' => 'プランのクォータと上限。', - 'section_ai' => 'AI クレジット', - 'section_ai_description' => 'クレジットは AI 機能の利用に応じて消費されます。毎月 1 日にリセットされます。', - - 'workspaces' => 'ワークスペース', - 'social_accounts' => 'ソーシャルアカウント', - 'members' => 'メンバー', - 'credits' => 'クレジット', -]; diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php index 24eaac618..e5354093c 100644 --- a/lang/ja/welcome.php +++ b/lang/ja/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'アカウントのオーナーは :name です。', 'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。', 'progress' => 'ようこそ進捗', - 'go_to_step' => 'ステップ :step へ', - 'step_current' => 'ステップ :step(現在)', + 'step_of' => 'ステップ :step / :total', + 'back' => '戻る', + 'steps' => [ + 'persona' => 'あなたについて', + 'goals' => '目標', + 'referral_source' => '知ったきっかけ', + 'connect' => 'SNS', + 'plan' => 'プラン', + ], + 'preview' => [ + 'heading' => 'ワークスペースが形になってきました。', + 'pending' => 'まだ未選択', + 'networks_empty' => 'まだSNSが連携されていません', + ], 'personas' => [ 'creator' => 'コンテンツクリエイター', 'freelancer' => 'フリーランス', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', 'ai_content' => 'TryPost AI で投稿を生成する', - 'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する', + 'use_mcp' => 'Claude・ChatGPT などから投稿を作成する', 'plan_calendar' => 'カレンダーで投稿を計画する', 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', @@ -38,6 +50,8 @@ 'just_exploring' => '今はまだ様子を見ている', 'other' => 'その他', ], + 'plan_title' => 'プランを選ぶ', + 'plan_description' => '2つのプランの機能は同じです。違いはワークスペースの数だけで、プランはいつでも変更できます。', 'referral_source_title' => 'どこで私たちを知りましたか?', 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', 'referral_source' => [ diff --git a/lang/ja/workspaces.php b/lang/ja/workspaces.php index 013fbfd65..ceaed055c 100644 --- a/lang/ja/workspaces.php +++ b/lang/ja/workspaces.php @@ -9,6 +9,13 @@ 'current' => '現在', 'connections' => ':count 件の接続', 'posts' => ':count 件の投稿', + 'subscription_required' => 'ワークスペースを追加するにはサブスクリプションが必要です。', + 'limit_reached' => 'このプランにはワークスペースが1つ含まれます。追加するにはアップグレードしてください。', + + 'upgrade_dialog' => [ + 'title' => 'ワークスペースを追加するにはアップグレード', + 'description' => '現在のプランにはワークスペースが1つ含まれます。ブランドやクライアントごとに1つ、無制限のワークスペースを使える Workspaces にアップグレードしましょう。', + ], 'create' => [ 'page_title' => 'ワークスペースを作成', diff --git a/lang/ko/accounts.php b/lang/ko/accounts.php index 61946bd3e..bcacc592f 100644 --- a/lang/ko/accounts.php +++ b/lang/ko/accounts.php @@ -6,6 +6,17 @@ 'description' => '연결된 모든 소셜 계정 개요', 'connect_cta' => '연결', 'connect_another' => '다른 계정 연결', + 'actions' => '계정 작업', + 'activate' => '켜기', + 'deactivate' => '끄기', + 'active' => '활성', + 'paused' => '꺼짐', + 'accounts_count' => '계정 :count개', + + 'variants' => [ + 'linkedin-page' => '페이지', + 'instagram-facebook' => 'Facebook 경유', + ], 'not_connected' => '연결 안 됨', 'connect' => '연결', diff --git a/lang/ko/billing.php b/lang/ko/billing.php index 43b33b21f..fd4ce2d44 100644 --- a/lang/ko/billing.php +++ b/lang/ko/billing.php @@ -9,57 +9,82 @@ 'cta' => '결제 수단 업데이트', ], - 'annual_banner' => [ - 'title' => '2개월 무료 받기', - 'description' => '연간 결제로 전환하고 매달 더 적게 지불하세요 — 같은 요금제, 그 외에는 아무것도 바뀌지 않습니다.', - 'cta' => '연간 결제로 업그레이드', - ], - 'subscribe' => [ 'billed_monthly' => '월간 결제', - 'billed_yearly' => '연간 결제', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], ], - 'plan' => [ + 'plans' => [ 'title' => '요금제', - 'description' => '구독 요금제를 관리하세요.', - 'label' => '요금제', - 'workspaces' => '{1}:count개 워크스페이스|[2,*]:count개 워크스페이스', - 'per_workspace' => '워크스페이스당', - 'price' => '가격', - 'month' => '월', + 'description' => '언제든지 업그레이드하거나 다운그레이드하세요.', + 'monthly' => '월간', + 'yearly' => '연간', + 'save_two_months' => '2개월 무료', + 'per_month' => '/월', + 'workspaces_one' => '워크스페이스 하나', + 'workspaces_unlimited' => '무제한 워크스페이스', + 'workspaces_tooltip' => '워크스페이스는 브랜드나 고객 하나를 나머지와 분리한 공간입니다. 소셜 계정, 서명, 라벨, 분석, 멤버 권한, MCP 연결을 각각 따로 가집니다.', + 'current' => '현재 요금제', + 'switch_to_yearly' => '연간으로 전환', + 'switch_to_monthly' => '월간으로 전환', + 'select' => ':plan 선택', + 'upgrade' => ':plan(으)로 업그레이드', + 'downgrade' => ':plan(으)로 다운그레이드', + 'start_first_month' => ':price에 시작하기', + 'per_first_month' => '/첫 달', + 'then_monthly' => '이후 :price/월', + 'billed_yearly_total' => '연간 결제 · :price (2개월 무료)', + 'socials_tagline' => '크리에이터와 소규모 브랜드에 적합.', + 'workspaces_tagline' => '에이전시와 대규모 비즈니스에 적합.', + 'everything_included' => '모두 포함', + 'features' => [ + 'networks_all' => '모든 소셜 네트워크 포함', + 'networks_all_tooltip' => '이 모든 네트워크에 게시할 수 있습니다.', + 'accounts_unlimited' => '소셜 계정 무제한', + 'accounts_unlimited_tooltip' => '원하는 만큼 계정을 연결할 수 있고, 같은 네트워크 여러 개도 가능합니다. 예를 들어 인스타그램 3개.', + 'calendar' => '캘린더: 월·주·일 보기', + 'calendar_tooltip' => '한 달을 한눈에: 계획 중, 예약됨, 이미 게시된 게시물을 모두 볼 수 있어요. 자세히 보려면 주 또는 일 보기로 전환하세요.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => '게시물 작성과 검토를 돕는 AI 어시스턴트.', + 'mcp' => 'MCP: Claude, ChatGPT, Grok에서 게시', + 'mcp_tooltip' => 'Claude, ChatGPT, Grok을 워크스페이스에 연결하세요. 게시물 작성과 예약, 지표 조회, 성과가 좋았던 게시물 파악, 내 데이터를 바탕으로 다음 콘텐츠 계획까지 맡길 수 있습니다.', + 'repurpose' => 'Repurpose: 게시물 하나를 여러 개로', + 'repurpose_tooltip' => '원본 계정을 선택하세요. 거기에 올리는 모든 새 게시물이 다른 네트워크에 자동으로 다시 게시됩니다. TryPost를 열 필요가 없어요.', + 'analytics' => '분석', + 'analytics_tooltip' => '노출, 도달, 좋아요, 댓글 같은 지표를 모든 게시물과 계정별로 한곳에서 확인하세요.', + 'team' => '멤버 수 무제한', + 'team_tooltip' => '추가 비용 없이 팀에 원하는 만큼 초대하세요. 각자가 할 수 있는 일을 정하고 게시 전에 승인할 수 있습니다.', + ], + ], + + 'plan' => [ 'trial' => '체험', - 'active' => '활성', - 'past_due' => '연체', 'cancelling' => '취소 중', 'trial_ends' => '체험 종료', ], 'subscription' => [ - 'title' => '구독', - 'description' => '결제 수단, 청구 정보, 구독을 관리하세요.', - 'payment_method' => '결제 수단', + 'title' => '결제 수단', + 'description' => '카드나 청구 정보는 Stripe에서 업데이트하세요.', 'no_payment_method' => '아직 등록된 결제 수단이 없습니다.', 'expires_on' => ':month/:year 만료', - 'manage_label' => '구독', 'manage_stripe' => 'Stripe에서 관리', ], 'invoices' => [ 'title' => '청구서', 'description' => '지난 청구서를 다운로드하세요.', - 'empty' => '청구서를 찾을 수 없습니다', 'paid' => '결제 완료', ], 'flash' => [ 'plan_changed' => '이제 :plan 요금제를 사용 중입니다.', - 'switched_to_yearly' => '이제 연간 결제를 사용 중입니다.', 'cannot_manage' => '계정 소유자만 결제를 관리할 수 있습니다.', - 'credits_exhausted' => 'AI 크레딧 소진 — 월 :limit 한도를 모두 사용했습니다. 요금제를 업그레이드하거나 다음 달까지 기다려 주세요.', + 'too_many_workspaces' => '워크스페이스가 :count개 있습니다. 이 요금제는 :limit개까지입니다. 전환하기 전에 나머지를 삭제하세요.', 'subscription_required' => 'AI 기능을 사용하려면 활성 구독이 필요합니다.', ], @@ -67,10 +92,5 @@ 'page_title' => '처리 중...', 'title' => '구독을 처리하는 중', 'description' => '계정을 설정하는 동안 잠시 기다려 주세요. 잠깐이면 됩니다.', - 'success_title' => '모든 준비가 끝났습니다!', - 'success_description' => '구독이 활성화되었습니다. 워크스페이스로 이동하는 중...', - 'cancelled_title' => '결제 취소됨', - 'cancelled_description' => '결제가 취소되었습니다. 요금이 청구되지 않았습니다.', - 'retry' => '다시 시도', ], ]; diff --git a/lang/ko/onboarding.php b/lang/ko/onboarding.php deleted file mode 100644 index f9fb24008..000000000 --- a/lang/ko/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - '시작하기', - 'welcome' => 'TryPost에 오신 걸 환영해요, :name', - 'description' => '아래 단계를 따라 TryPost 사용법을 확인하고 첫 게시물을 발행하세요.', - 'skip_step' => '이 단계 건너뛰기', - 'continue' => 'TryPost로 계속', - 'status' => [ - 'complete' => '완료', - 'todo' => '할 일', - 'skipped' => '건너뜀', - ], - 'mcp' => [ - 'title' => 'AI 어시스턴트 연결', - 'description' => 'TryPost를 MCP 서버로 추가하면 어시스턴트가 소셜 게시물을 만들고 관리할 수 있어요.', - 'copied' => 'MCP URL이 복사되었어요.', - ], - 'social' => [ - 'title' => '소셜 계정 연결', - 'description' => 'TryPost가 콘텐츠를 발행할 네트워크를 하나 이상 선택하세요.', - 'connected_elsewhere' => '다른 워크스페이스에서 이미 계정을 연결했으므로 이 단계는 완료되었습니다.', - ], - 'first_post' => [ - 'title' => '첫 게시물 만들기', - 'description' => '연결된 어시스턴트에서 이 시작 프롬프트를 써 보거나, TryPost에서 바로 게시물을 만드세요.', - 'prompt_label' => '샘플 프롬프트', - 'sample_prompt' => '내 브랜드를 소개하는 친근한 소셜 게시물을 만들고 연결된 각 네트워크에 맞게 조정해 주세요.', - 'copy_prompt' => '프롬프트 복사', - 'copied' => '샘플 프롬프트가 복사되었어요.', - 'create_button' => '첫 게시물 만들기', - 'or' => '또는', - ], - 'ready' => [ - 'title' => '발행할 준비가 됐어요', - 'description' => '설정이 끝났어요. TryPost로 가서 콘텐츠 계획을 시작하세요.', - ], -]; diff --git a/lang/ko/settings.php b/lang/ko/settings.php index 77fb6569b..e4a9a4262 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => '계정', - 'usage' => '사용량', 'billing' => '결제', ], 'title' => '계정 설정', diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index 89f83b9ed..8302fc8d2 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => '계정 및 결제', 'workspace_settings' => '워크스페이스 설정', 'log_out' => '로그아웃', + 'help' => '도움말', 'workspace' => [ 'connections' => '연결', 'signatures' => '서명', @@ -27,8 +28,6 @@ ], 'analytics' => '분석', 'repurposes' => 'Repurpose', - 'onboarding' => '시작하기', - 'onboarding_hint' => '설정 마치기', 'posts' => [ 'calendar' => '캘린더', 'all' => '전체', @@ -42,8 +41,10 @@ 'archive_all' => '모두 보관', 'no_notifications' => '알림 없음', 'support' => [ + 'chat' => '채팅 지원', 'docs' => '문서', 'referral' => '30% 추천 수익 받기', 'discord' => 'Discord 커뮤니티', + 'community' => '커뮤니티', ], ]; diff --git a/lang/ko/usage.php b/lang/ko/usage.php deleted file mode 100644 index b92e7ff79..000000000 --- a/lang/ko/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - '사용량', - - 'section_account' => '계정', - 'section_account_description' => '요금제의 할당량 및 한도.', - 'section_ai' => 'AI 크레딧', - 'section_ai_description' => '크레딧은 AI 기능을 사용할 때 차감됩니다. 매월 1일에 초기화됩니다.', - - 'workspaces' => '워크스페이스', - 'social_accounts' => '소셜 계정', - 'members' => '멤버', - 'credits' => '크레딧', -]; diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php index 8156ae271..776003b2b 100644 --- a/lang/ko/welcome.php +++ b/lang/ko/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => '계정 소유자는 :name 님입니다.', 'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.', 'progress' => '환영 진행률', - 'go_to_step' => ':step단계로 이동', - 'step_current' => ':step단계 (현재)', + 'step_of' => ':total단계 중 :step단계', + 'back' => '뒤로', + 'steps' => [ + 'persona' => '나에 대해', + 'goals' => '목표', + 'referral_source' => '알게 된 경로', + 'connect' => '소셜 네트워크', + 'plan' => '요금제', + ], + 'preview' => [ + 'heading' => '워크스페이스가 만들어지고 있어요.', + 'pending' => '아직 선택하지 않음', + 'networks_empty' => '아직 연결된 네트워크가 없어요', + ], 'personas' => [ 'creator' => '콘텐츠 크리에이터', 'freelancer' => '프리랜서', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', 'ai_content' => 'TryPost AI로 게시물 생성', - 'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성', + 'use_mcp' => 'Claude, ChatGPT 등에서 게시물 작성', 'plan_calendar' => '캘린더에서 게시물 계획', 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', 'grow_audience' => '팔로워와 참여 늘리기', @@ -38,6 +50,8 @@ 'just_exploring' => '지금은 둘러보는 중', 'other' => '다른 것', ], + 'plan_title' => '요금제 선택', + 'plan_description' => '두 플랜의 기능은 같습니다. 차이는 워크스페이스 수이고, 플랜은 언제든 바꿀 수 있습니다.', 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', 'referral_source' => [ diff --git a/lang/ko/workspaces.php b/lang/ko/workspaces.php index e1f6b949f..ea201144a 100644 --- a/lang/ko/workspaces.php +++ b/lang/ko/workspaces.php @@ -9,6 +9,13 @@ 'current' => '현재', 'connections' => ':count개 연결', 'posts' => ':count개 게시물', + 'subscription_required' => '워크스페이스를 더 만들려면 구독하세요.', + 'limit_reached' => '이 요금제에는 워크스페이스가 하나 포함됩니다. 더 추가하려면 업그레이드하세요.', + + 'upgrade_dialog' => [ + 'title' => '워크스페이스를 추가하려면 업그레이드하세요', + 'description' => '현재 요금제에는 워크스페이스가 하나 포함됩니다. 브랜드나 클라이언트마다 하나씩, 무제한 워크스페이스를 쓰려면 Workspaces로 업그레이드하세요.', + ], 'create' => [ 'page_title' => '워크스페이스 만들기', diff --git a/lang/nl/accounts.php b/lang/nl/accounts.php index d15f68800..04850b074 100644 --- a/lang/nl/accounts.php +++ b/lang/nl/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Overzicht van al je gekoppelde social accounts', 'connect_cta' => 'Koppelen', 'connect_another' => 'Nog een koppelen', + 'actions' => 'Accountacties', + 'activate' => 'Inschakelen', + 'deactivate' => 'Uitschakelen', + 'active' => 'Actief', + 'paused' => 'Uit', + 'accounts_count' => ':count account|:count accounts', + + 'variants' => [ + 'linkedin-page' => 'Pagina', + 'instagram-facebook' => 'via Facebook', + ], 'not_connected' => 'Niet gekoppeld', 'connect' => 'Koppelen', diff --git a/lang/nl/billing.php b/lang/nl/billing.php index 722e46c16..03988b433 100644 --- a/lang/nl/billing.php +++ b/lang/nl/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Betaling bijwerken', ], - 'annual_banner' => [ - 'title' => 'Krijg 2 maanden gratis', - 'description' => 'Stap over op jaarlijkse facturatie en betaal elke maand minder — hetzelfde abonnement, verder verandert er niets.', - 'cta' => 'Upgraden naar jaarlijks', - ], - 'subscribe' => [ 'billed_monthly' => 'Maandelijks gefactureerd', - 'billed_yearly' => 'Jaarlijks gefactureerd', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Plannen', + 'description' => 'Upgrade of downgrade wanneer je wilt.', + 'monthly' => 'Maandelijks', + 'yearly' => 'Jaarlijks', + 'save_two_months' => '2 maanden gratis', + 'per_month' => '/maand', + '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.', + 'current' => 'Huidig plan', + 'switch_to_yearly' => 'Overschakelen naar jaarlijks', + 'switch_to_monthly' => 'Overschakelen naar maandelijks', + 'select' => 'Kies :plan', + 'upgrade' => 'Upgrade naar :plan', + 'downgrade' => 'Downgrade naar :plan', + 'start_first_month' => 'Start voor :price', + 'per_first_month' => '/eerste maand', + 'then_monthly' => 'Daarna :price/maand', + 'billed_yearly_total' => 'Jaarlijks gefactureerd · :price (2 maanden gratis)', + 'socials_tagline' => 'Ideaal voor creators en kleine merken.', + 'workspaces_tagline' => 'Ideaal voor bureaus en grotere bedrijven.', + 'everything_included' => 'Alles inbegrepen', + 'features' => [ + 'networks_all' => 'Alle sociale netwerken inbegrepen', + 'networks_all_tooltip' => 'Je kunt op al deze netwerken posten.', + 'accounts_unlimited' => 'Onbeperkte social accounts', + 'accounts_unlimited_tooltip' => 'Koppel zoveel accounts als je wilt, ook meerdere van hetzelfde netwerk. Drie Instagrams, bijvoorbeeld.', + 'calendar' => 'Kalender: maand-, week- en dagweergave', + 'calendar_tooltip' => 'Zie je hele maand in één oogopslag: wat gepland, ingepland en al gepubliceerd is. Schakel naar week of dag als je detail nodig hebt.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Je AI-assistent voor het schrijven en nakijken van posts.', + 'mcp' => 'MCP: post via Claude, ChatGPT of Grok', + 'mcp_tooltip' => 'Koppel Claude, ChatGPT of Grok aan je workspace. Laat het posts maken en inplannen, statistieken ophalen, zien wat het beste werkte en op basis van je eigen data plannen wat hierna komt.', + 'repurpose' => 'Repurpose: maak van één post er veel', + 'repurpose_tooltip' => 'Kies een bronaccount. Elke nieuwe post die je daar plaatst, wordt automatisch op je andere netwerken geplaatst. Je hoeft TryPost niet te openen.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Krijg statistieken zoals impressies, bereik, likes en reacties voor elke post en elk account, alles op één plek.', + 'team' => 'Onbeperkt aantal leden', + 'team_tooltip' => 'Nodig zoveel mensen uit voor je team als je wilt, zonder extra kosten. Bepaal wat iedereen mag doen en keur posts goed voordat ze live gaan.', ], ], 'plan' => [ - 'title' => 'Abonnement', - 'description' => 'Beheer je abonnement.', - 'label' => 'Abonnement', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'per workspace', - 'price' => 'Prijs', - 'month' => 'maand', 'trial' => 'Proefperiode', - 'active' => 'Actief', - 'past_due' => 'Achterstallig', 'cancelling' => 'Wordt opgezegd', 'trial_ends' => 'Proefperiode eindigt', ], 'subscription' => [ - 'title' => 'Abonnement', - 'description' => 'Beheer je betaalmethode, factuurgegevens en abonnement.', - 'payment_method' => 'Betaalmethode', + 'title' => 'Betaalmethode', + 'description' => 'Werk je kaart of factuurgegevens bij in Stripe.', 'no_payment_method' => 'Nog geen betaalmethode geregistreerd.', 'expires_on' => 'Verloopt :month/:year', - 'manage_label' => 'Abonnement', 'manage_stripe' => 'Beheren op Stripe', ], 'invoices' => [ 'title' => 'Facturen', 'description' => 'Download je eerdere facturen.', - 'empty' => 'Geen facturen gevonden', 'paid' => 'Betaald', ], 'flash' => [ 'plan_changed' => 'Je zit nu op het :plan-abonnement.', - 'switched_to_yearly' => 'Je zit nu op jaarlijkse facturatie.', 'cannot_manage' => 'Alleen de accounteigenaar kan de facturatie beheren.', - 'credits_exhausted' => 'Geen AI-credits meer — je maandelijkse tegoed van :limit is opgebruikt. Upgrade je abonnement of wacht tot volgende maand.', + 'too_many_workspaces' => 'Je hebt :count workspaces. Dit plan bevat er :limit — verwijder de extra\'s voordat je wisselt.', 'subscription_required' => 'Een actief abonnement is vereist om AI-functies te gebruiken.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Verwerken...', 'title' => 'Je abonnement wordt verwerkt', 'description' => 'Wacht even terwijl we je account instellen. Dit duurt maar een moment.', - 'success_title' => 'Je bent helemaal klaar!', - 'success_description' => 'Je abonnement is actief. Je wordt doorgestuurd naar je workspaces...', - 'cancelled_title' => 'Afrekenen geannuleerd', - 'cancelled_description' => 'Je afrekenen is geannuleerd. Er zijn geen kosten in rekening gebracht.', - 'retry' => 'Opnieuw proberen', ], ]; diff --git a/lang/nl/onboarding.php b/lang/nl/onboarding.php deleted file mode 100644 index 025029e4a..000000000 --- a/lang/nl/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Aan de slag', - 'welcome' => 'Welkom bij TryPost, :name', - 'description' => 'Volg de stappen hieronder om te zien hoe TryPost werkt en je eerste post te publiceren.', - 'skip_step' => 'Deze stap overslaan', - 'continue' => 'Doorgaan naar TryPost', - 'status' => [ - 'complete' => 'Voltooid', - 'todo' => 'Te doen', - 'skipped' => 'Overgeslagen', - ], - 'mcp' => [ - 'title' => 'Koppel je AI-assistent', - 'description' => 'Voeg TryPost toe als MCP-server zodat je assistent social posts voor je kan maken en beheren.', - 'copied' => 'MCP-URL gekopieerd.', - ], - 'social' => [ - 'title' => 'Koppel een social account', - 'description' => 'Kies minstens één netwerk waar TryPost je content kan publiceren.', - 'connected_elsewhere' => 'Je hebt al een account gekoppeld in een andere workspace, dus deze stap is klaar.', - ], - 'first_post' => [ - 'title' => 'Maak je eerste post', - 'description' => 'Probeer deze startprompt met je gekoppelde assistent, of maak de post direct in TryPost.', - 'prompt_label' => 'Voorbeeldprompt', - 'sample_prompt' => 'Maak een vriendelijke social post die mijn merk introduceert en pas die aan voor elk gekoppeld netwerk.', - 'copy_prompt' => 'Prompt kopiëren', - 'copied' => 'Voorbeeldprompt gekopieerd.', - 'create_button' => 'Je eerste post maken', - 'or' => 'of', - ], - 'ready' => [ - 'title' => 'Je bent klaar om te publiceren', - 'description' => 'Alles staat. Ga door naar TryPost en begin je content te plannen.', - ], -]; diff --git a/lang/nl/settings.php b/lang/nl/settings.php index 46c1eb79d..c8082b25f 100644 --- a/lang/nl/settings.php +++ b/lang/nl/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Account', - 'usage' => 'Gebruik', 'billing' => 'Facturatie', ], 'title' => 'Accountinstellingen', diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index 5a59e747a..0d614278d 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Account en facturatie', 'workspace_settings' => 'Workspace-instellingen', 'log_out' => 'Uitloggen', + 'help' => 'Hulp', 'workspace' => [ 'connections' => 'Koppelingen', 'signatures' => 'Handtekeningen', @@ -27,8 +28,6 @@ ], 'analytics' => 'Statistieken', 'repurposes' => 'Repurpose', - 'onboarding' => 'Aan de slag', - 'onboarding_hint' => 'Setup afronden', 'posts' => [ 'calendar' => 'Kalender', 'all' => 'Alle', @@ -42,8 +41,10 @@ 'archive_all' => 'Alles archiveren', 'no_notifications' => 'Geen meldingen', 'support' => [ + 'chat' => 'Chatsupport', 'docs' => 'Documentatie', 'referral' => 'Verdien 30% referral', 'discord' => 'Discord-community', + 'community' => 'Community', ], ]; diff --git a/lang/nl/usage.php b/lang/nl/usage.php deleted file mode 100644 index 12e654287..000000000 --- a/lang/nl/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Gebruik', - - 'section_account' => 'Account', - 'section_account_description' => 'Quota en limieten voor je abonnement.', - 'section_ai' => 'AI-credits', - 'section_ai_description' => 'Credits worden afgeschreven naarmate AI-functies worden gebruikt. Ze worden op de eerste van elke maand gereset.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Social accounts', - 'members' => 'Leden', - 'credits' => 'Credits', -]; diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php index f6250a224..9b4ea8a1f 100644 --- a/lang/nl/welcome.php +++ b/lang/nl/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'De accounteigenaar is :name.', 'subscription_required_auto' => 'Deze pagina vernieuwt automatisch — verversen is niet nodig.', 'progress' => 'Welkomstvoortgang', - 'go_to_step' => 'Ga naar stap :step', - 'step_current' => 'Stap :step (huidig)', + 'step_of' => 'Stap :step van :total', + 'back' => 'Terug', + 'steps' => [ + 'persona' => 'Over jou', + 'goals' => 'Je doelen', + 'referral_source' => 'Hoe je ons vond', + 'connect' => 'Sociale netwerken', + 'plan' => 'Abonnement', + ], + 'preview' => [ + 'heading' => 'Je workspace krijgt vorm.', + 'pending' => 'Nog niet gekozen', + 'networks_empty' => 'Nog geen netwerken verbonden', + ], 'personas' => [ 'creator' => 'Contentmaker', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Tijd besparen door overal tegelijk te posten', 'ai_content' => 'Posts genereren met TryPost AI', - 'use_mcp' => 'Posts maken via Claude, ChatGPT of Cursor', + 'use_mcp' => 'Posts maken via Claude, ChatGPT, enz.', 'plan_calendar' => 'Mijn posts plannen op een kalender', 'stay_on_brand' => 'Elke post in lijn met mijn merk houden', 'grow_audience' => 'Mijn publiek en betrokkenheid laten groeien', @@ -38,6 +50,8 @@ 'just_exploring' => 'Voorlopig gewoon aan het verkennen', 'other' => 'Iets anders', ], + 'plan_title' => 'Kies je abonnement', + 'plan_description' => 'Beide plannen hebben dezelfde functies. Het verschil is het aantal workspaces, en je kunt wisselen wanneer je wilt.', 'referral_source_title' => 'Hoe heb je ons gevonden?', 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', 'referral_source' => [ diff --git a/lang/nl/workspaces.php b/lang/nl/workspaces.php index 973430eb2..e91888bbd 100644 --- a/lang/nl/workspaces.php +++ b/lang/nl/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Huidige', 'connections' => ':count koppelingen', 'posts' => ':count posts', + 'subscription_required' => 'Abonneer je om meer workspaces te maken.', + 'limit_reached' => 'Je plan bevat één workspace. Upgrade om er meer toe te voegen.', + + 'upgrade_dialog' => [ + 'title' => 'Upgrade om workspaces toe te voegen', + 'description' => 'Je plan bevat één workspace. Upgrade naar Workspaces voor onbeperkte workspaces — één per merk of klant.', + ], 'create' => [ 'page_title' => 'Maak je workspace aan', diff --git a/lang/pl/accounts.php b/lang/pl/accounts.php index e9e1ebdbd..6e4a4ce73 100644 --- a/lang/pl/accounts.php +++ b/lang/pl/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Przegląd wszystkich Twoich połączonych kont społecznościowych', 'connect_cta' => 'Połącz', 'connect_another' => 'Połącz kolejne', + 'actions' => 'Akcje konta', + 'activate' => 'Włącz', + 'deactivate' => 'Wyłącz', + 'active' => 'Aktywne', + 'paused' => 'Wyłączone', + 'accounts_count' => ':count konto|:count konta|:count kont', + + 'variants' => [ + 'linkedin-page' => 'Strona', + 'instagram-facebook' => 'przez Facebooka', + ], 'not_connected' => 'Niepołączone', 'connect' => 'Połącz', diff --git a/lang/pl/billing.php b/lang/pl/billing.php index 4283acf93..6a9e11087 100644 --- a/lang/pl/billing.php +++ b/lang/pl/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Zaktualizuj płatność', ], - 'annual_banner' => [ - 'title' => 'Otrzymaj 2 miesiące gratis', - 'description' => 'Przejdź na rozliczenie roczne i płać mniej każdego miesiąca — ten sam plan, nic więcej się nie zmienia.', - 'cta' => 'Przejdź na plan roczny', - ], - 'subscribe' => [ 'billed_monthly' => 'Rozliczane miesięcznie', - 'billed_yearly' => 'Rozliczane rocznie', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Plany', + 'description' => 'Zmień plan w dowolnym momencie.', + 'monthly' => 'Miesięcznie', + 'yearly' => 'Rocznie', + 'save_two_months' => '2 miesiące gratis', + 'per_month' => '/miesiąc', + '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.', + 'current' => 'Aktualny plan', + 'switch_to_yearly' => 'Przełącz na roczny', + 'switch_to_monthly' => 'Przełącz na miesięczny', + 'select' => 'Wybierz :plan', + 'upgrade' => 'Przejdź na :plan', + 'downgrade' => 'Zmień na :plan', + 'start_first_month' => 'Zacznij za :price', + 'per_first_month' => '/pierwszy miesiąc', + 'then_monthly' => 'Potem :price/mies.', + 'billed_yearly_total' => 'Rozliczane rocznie · :price (2 miesiące gratis)', + 'socials_tagline' => 'Dla twórców i małych marek.', + 'workspaces_tagline' => 'Dla agencji i większych firm.', + 'everything_included' => 'Wszystko w cenie', + 'features' => [ + 'networks_all' => 'Wszystkie sieci społecznościowe w cenie', + 'networks_all_tooltip' => 'Możesz publikować we wszystkich tych sieciach.', + 'accounts_unlimited' => 'Nielimitowane konta społecznościowe', + 'accounts_unlimited_tooltip' => 'Podłącz tyle kont, ile chcesz, także kilka z tej samej sieci. Na przykład trzy Instagramy.', + 'calendar' => 'Kalendarz: widok miesiąca, tygodnia i dnia', + 'calendar_tooltip' => 'Zobacz cały miesiąc na jednym ekranie: co zaplanowane, ustawione w kolejce i już opublikowane. Przełącz na tydzień lub dzień, gdy potrzebujesz szczegółów.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Twój asystent AI do pisania i poprawiania postów.', + 'mcp' => 'MCP: publikuj z Claude, ChatGPT lub Grok', + 'mcp_tooltip' => 'Podłącz Claude, ChatGPT lub Grok do swojego workspace. Poproś o tworzenie i planowanie postów, pobieranie metryk, wskazanie najlepszych postów i planowanie kolejnych treści na podstawie Twoich danych.', + 'repurpose' => 'Repurpose: z jednego posta zrób wiele', + 'repurpose_tooltip' => 'Wybierz konto źródłowe. Każdy nowy post, który tam opublikujesz, jest automatycznie publikowany w Twoich pozostałych sieciach. Nie musisz otwierać TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Zbieraj metryki, takie jak wyświetlenia, zasięg, polubienia i komentarze, dla każdego posta i każdego konta, wszystko w jednym miejscu.', + 'team' => 'Nielimitowani członkowie', + 'team_tooltip' => 'Zaproś do zespołu tyle osób, ile chcesz, bez dodatkowych opłat. Ustal, co każda może robić, i zatwierdzaj posty przed publikacją.', ], ], 'plan' => [ - 'title' => 'Plan', - 'description' => 'Zarządzaj swoim planem subskrypcji.', - 'label' => 'Plan', - 'workspaces' => ':count przestrzeń robocza|:count przestrzenie robocze|:count przestrzeni roboczych', - 'per_workspace' => 'za przestrzeń roboczą', - 'price' => 'Cena', - 'month' => 'miesiąc', 'trial' => 'Okres próbny', - 'active' => 'Aktywny', - 'past_due' => 'Zaległa płatność', 'cancelling' => 'Anulowanie', 'trial_ends' => 'Okres próbny kończy się', ], 'subscription' => [ - 'title' => 'Subskrypcja', - 'description' => 'Zarządzaj metodą płatności, danymi rozliczeniowymi i subskrypcją.', - 'payment_method' => 'Metoda płatności', + 'title' => 'Metoda płatności', + 'description' => 'Zaktualizuj kartę lub dane rozliczeniowe w Stripe.', 'no_payment_method' => 'Brak zapisanej metody płatności.', 'expires_on' => 'Wygasa :month/:year', - 'manage_label' => 'Subskrypcja', 'manage_stripe' => 'Zarządzaj w Stripe', ], 'invoices' => [ 'title' => 'Faktury', 'description' => 'Pobierz swoje wcześniejsze faktury.', - 'empty' => 'Nie znaleziono faktur', 'paid' => 'Opłacona', ], 'flash' => [ 'plan_changed' => 'Korzystasz teraz z planu :plan.', - 'switched_to_yearly' => 'Korzystasz teraz z rozliczenia rocznego.', 'cannot_manage' => 'Tylko właściciel konta może zarządzać rozliczeniami.', - 'credits_exhausted' => 'Brak kredytów AI — Twój miesięczny limit :limit został wykorzystany. Ulepsz plan lub poczekaj do następnego miesiąca.', + 'too_many_workspaces' => 'Masz :count workspace’ów. Ten plan obejmuje :limit — usuń nadmiar przed zmianą.', 'subscription_required' => 'Aby korzystać z funkcji AI, wymagana jest aktywna subskrypcja.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Przetwarzanie...', 'title' => 'Przetwarzanie Twojej subskrypcji', 'description' => 'Poczekaj, aż skonfigurujemy Twoje konto. Zajmie to tylko chwilę.', - 'success_title' => 'Wszystko gotowe!', - 'success_description' => 'Twoja subskrypcja jest aktywna. Przekierowujemy Cię do Twoich przestrzeni roboczych...', - 'cancelled_title' => 'Anulowano płatność', - 'cancelled_description' => 'Twoja płatność została anulowana. Nie pobrano żadnych opłat.', - 'retry' => 'Spróbuj ponownie', ], ]; diff --git a/lang/pl/onboarding.php b/lang/pl/onboarding.php deleted file mode 100644 index f2c2e7933..000000000 --- a/lang/pl/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Pierwsze kroki', - 'welcome' => 'Witaj w TryPost, :name', - 'description' => 'Wykonaj poniższe kroki, aby zobaczyć, jak działa TryPost, i opublikować pierwszy post.', - 'skip_step' => 'Pomiń ten krok', - 'continue' => 'Przejdź do TryPost', - 'status' => [ - 'complete' => 'Ukończone', - 'todo' => 'Do zrobienia', - 'skipped' => 'Pominięty', - ], - 'mcp' => [ - 'title' => 'Połącz asystenta AI', - 'description' => 'Dodaj TryPost jako serwer MCP, aby asystent mógł tworzyć i zarządzać postami społecznościowymi za Ciebie.', - 'copied' => 'URL MCP skopiowany.', - ], - 'social' => [ - 'title' => 'Połącz konto społecznościowe', - 'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści.', - 'connected_elsewhere' => 'Masz już połączone konto w innym workspace, więc ten krok jest ukończony.', - ], - 'first_post' => [ - 'title' => 'Utwórz pierwszy post', - 'description' => 'Wypróbuj ten prompt startowy z podłączonym asystentem albo utwórz post bezpośrednio w TryPost.', - 'prompt_label' => 'Przykładowy prompt', - 'sample_prompt' => 'Utwórz przyjazny post społecznościowy przedstawiający moją markę i dostosuj go do każdej podłączonej sieci.', - 'copy_prompt' => 'Kopiuj prompt', - 'copied' => 'Przykładowy prompt skopiowany.', - 'create_button' => 'Utwórz pierwszy post', - 'or' => 'lub', - ], - 'ready' => [ - 'title' => 'Możesz już publikować', - 'description' => 'Wszystko gotowe. Przejdź do TryPost i zacznij planować treści.', - ], -]; diff --git a/lang/pl/settings.php b/lang/pl/settings.php index 89520348f..9762d1db1 100644 --- a/lang/pl/settings.php +++ b/lang/pl/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Konto', - 'usage' => 'Wykorzystanie', 'billing' => 'Rozliczenia', ], 'title' => 'Ustawienia konta', diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index 3d682ef8a..e55083a96 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Konto i płatności', 'workspace_settings' => 'Ustawienia workspace', 'log_out' => 'Wyloguj się', + 'help' => 'Pomoc', 'workspace' => [ 'connections' => 'Połączenia', 'signatures' => 'Sygnatury', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analityka', 'repurposes' => 'Repurpose', - 'onboarding' => 'Pierwsze kroki', - 'onboarding_hint' => 'Dokończ konfigurację', 'posts' => [ 'calendar' => 'Kalendarz', 'all' => 'Wszystkie', @@ -42,8 +41,10 @@ 'archive_all' => 'Zarchiwizuj wszystkie', 'no_notifications' => 'Brak powiadomień', 'support' => [ + 'chat' => 'Wsparcie na czacie', 'docs' => 'Dokumentacja', 'referral' => 'Zarabiaj 30% z poleceń', 'discord' => 'Społeczność Discord', + 'community' => 'Społeczność', ], ]; diff --git a/lang/pl/usage.php b/lang/pl/usage.php deleted file mode 100644 index b6ff40a78..000000000 --- a/lang/pl/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Wykorzystanie', - - 'section_account' => 'Konto', - 'section_account_description' => 'Limity i przydziały dla Twojego planu.', - 'section_ai' => 'Kredyty AI', - 'section_ai_description' => 'Kredyty są pobierane w miarę korzystania z funkcji AI. Odnawiają się pierwszego dnia każdego miesiąca.', - - 'workspaces' => 'Przestrzenie robocze', - 'social_accounts' => 'Konta społecznościowe', - 'members' => 'Członkowie', - 'credits' => 'Kredyty', -]; diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php index 3dbb28db9..4079c4fc8 100644 --- a/lang/pl/welcome.php +++ b/lang/pl/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Właścicielem Twojego konta jest :name.', 'subscription_required_auto' => 'Ta strona odświeża się automatycznie — nie musisz jej przeładowywać.', 'progress' => 'Postęp powitalny', - 'go_to_step' => 'Przejdź do kroku :step', - 'step_current' => 'Krok :step (bieżący)', + 'step_of' => 'Krok :step z :total', + 'back' => 'Wstecz', + 'steps' => [ + 'persona' => 'O Tobie', + 'goals' => 'Twoje cele', + 'referral_source' => 'Jak nas znalazłeś', + 'connect' => 'Sieci społecznościowe', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Twój workspace nabiera kształtu.', + 'pending' => 'Jeszcze nie wybrano', + 'networks_empty' => 'Nie połączono jeszcze żadnej sieci', + ], 'personas' => [ 'creator' => 'Twórca treści', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Oszczędzaj czas, publikując wszędzie naraz', 'ai_content' => 'Generuj posty z AI TryPost', - 'use_mcp' => 'Twórz posty w Claude, ChatGPT lub Cursor', + 'use_mcp' => 'Twórz posty w Claude, ChatGPT itd.', 'plan_calendar' => 'Planuj posty w kalendarzu', 'stay_on_brand' => 'Utrzymuj każdy post spójny z marką', 'grow_audience' => 'Powiększaj grono odbiorców i zaangażowanie', @@ -38,6 +50,8 @@ 'just_exploring' => 'Na razie tylko się rozglądam', 'other' => 'Coś innego', ], + 'plan_title' => 'Wybierz plan', + 'plan_description' => 'Oba plany mają te same funkcje. Różnią się liczbą workspace’ów, a plan możesz zmienić w każdej chwili.', 'referral_source_title' => 'Jak nas znalazłeś?', 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', 'referral_source' => [ diff --git a/lang/pl/workspaces.php b/lang/pl/workspaces.php index bd836574e..d84f5a058 100644 --- a/lang/pl/workspaces.php +++ b/lang/pl/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Bieżąca', 'connections' => ':count połączeń', 'posts' => ':count postów', + 'subscription_required' => 'Subskrybuj, aby tworzyć więcej workspace\'ów.', + 'limit_reached' => 'Twój plan obejmuje jeden workspace. Ulepsz plan, aby dodać więcej.', + + 'upgrade_dialog' => [ + 'title' => 'Ulepsz plan, aby dodać workspaces', + 'description' => 'Twój plan obejmuje jeden workspace. Przejdź na Workspaces, aby mieć nielimitowane workspaces — po jednym na markę lub klienta.', + ], 'create' => [ 'page_title' => 'Utwórz przestrzeń roboczą', diff --git a/lang/pt-BR/accounts.php b/lang/pt-BR/accounts.php index 7b816cfab..8e54c04bb 100644 --- a/lang/pt-BR/accounts.php +++ b/lang/pt-BR/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Visão geral de todas as suas contas sociais conectadas', 'connect_cta' => 'Conectar', 'connect_another' => 'Conectar outra', + 'actions' => 'Ações da conta', + 'activate' => 'Ativar', + 'deactivate' => 'Desativar', + 'active' => 'Ativa', + 'paused' => 'Desativada', + 'accounts_count' => ':count conta|:count contas', + + 'variants' => [ + 'linkedin-page' => 'Página', + 'instagram-facebook' => 'via Facebook', + ], 'not_connected' => 'Não conectado', 'connect' => 'Conectar', diff --git a/lang/pt-BR/billing.php b/lang/pt-BR/billing.php index 52e5e9f78..e8ef22883 100644 --- a/lang/pt-BR/billing.php +++ b/lang/pt-BR/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Atualizar pagamento', ], - 'annual_banner' => [ - 'title' => 'Ganhe 2 meses grátis', - 'description' => 'Mude para a cobrança anual e pague menos por mês — sem mudar nada do seu plano.', - 'cta' => 'Mudar para anual', - ], - 'subscribe' => [ 'billed_monthly' => 'Cobrança mensal', - 'billed_yearly' => 'Cobrança anual', 'prices' => [ - 'workspace' => ['monthly' => 'R$ 60', 'yearly_per_month' => 'R$ 50', 'yearly' => 'R$ 600'], + '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'], + ], + ], + + 'plans' => [ + 'title' => 'Planos', + 'description' => 'Faça upgrade ou downgrade a qualquer momento.', + 'monthly' => 'Mensal', + 'yearly' => 'Anual', + 'save_two_months' => '2 meses grátis', + 'per_month' => '/mês', + '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.', + 'current' => 'Plano atual', + 'switch_to_yearly' => 'Mudar para anual', + 'switch_to_monthly' => 'Mudar para mensal', + 'select' => 'Escolher :plan', + 'upgrade' => 'Fazer upgrade para :plan', + 'downgrade' => 'Fazer downgrade para :plan', + 'start_first_month' => 'Começar por :price', + 'per_first_month' => '/primeiro mês', + 'then_monthly' => 'Depois, :price/mês', + 'billed_yearly_total' => 'Cobrança anual · :price (2 meses grátis)', + 'socials_tagline' => 'Indicado para criadores e pequenas marcas.', + 'workspaces_tagline' => 'Indicado para agências e grandes negócios.', + 'everything_included' => 'Tudo incluído', + 'features' => [ + 'networks_all' => 'Todas as redes sociais inclusas', + 'networks_all_tooltip' => 'Você pode publicar em todas estas redes.', + 'accounts_unlimited' => 'Redes sociais ilimitadas', + 'accounts_unlimited_tooltip' => 'Conecte quantas contas quiser, inclusive várias da mesma rede. Três Instagrams, por exemplo.', + 'calendar' => 'Calendário mensal, semanal e diário', + 'calendar_tooltip' => 'Veja o mês inteiro de uma vez: o que está planejado, agendado e já publicado. Mude para a semana ou o dia quando precisar de detalhe.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Seu assistente de IA para escrever e revisar posts.', + 'mcp' => 'MCP: poste pelo Claude, ChatGPT ou Grok', + 'mcp_tooltip' => 'Conecte o Claude, ChatGPT ou Grok ao seu workspace. Peça para criar e agendar posts, puxar métricas, ver o que performou melhor e planejar o próximo conteúdo com base nos seus dados.', + 'repurpose' => 'Repost: republique automaticamente nas outras redes', + 'repurpose_tooltip' => 'Escolha uma conta de origem. Cada post novo que você publicar nela é republicado automaticamente nas suas outras redes. Você não precisa abrir o TryPost.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Obtenha métricas como impressões, alcance, likes e comentários de cada post e de cada conta, tudo num só lugar.', + 'team' => 'Membros ilimitados', + 'team_tooltip' => 'Convide quantas pessoas quiser para o seu time, sem custo extra. Defina o que cada uma pode fazer e aprove os posts antes de publicar.', ], ], 'plan' => [ - 'title' => 'Plano', - 'description' => 'Gerencie seu plano de assinatura.', - 'label' => 'Plano', - 'workspaces' => '{1}:count workspace|[2,*]:count workspaces', - 'per_workspace' => 'por workspace', - 'price' => 'Preço', - 'month' => 'mês', 'trial' => 'Trial', - 'active' => 'Ativo', - 'past_due' => 'Vencido', 'cancelling' => 'Cancelando', 'trial_ends' => 'Teste termina em', ], 'subscription' => [ - 'title' => 'Assinatura', - 'description' => 'Gerencie seu método de pagamento, dados de cobrança e assinatura.', - 'payment_method' => 'Método de pagamento', + 'title' => 'Método de pagamento', + '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_label' => 'Assinatura', 'manage_stripe' => 'Gerenciar no Stripe', ], 'invoices' => [ 'title' => 'Faturas', 'description' => 'Baixe suas faturas anteriores.', - 'empty' => 'Nenhuma fatura encontrada', 'paid' => 'Pago', ], 'flash' => [ 'plan_changed' => 'Você está agora no plano :plan.', - 'switched_to_yearly' => 'Você está agora na cobrança anual.', 'cannot_manage' => 'Apenas o owner da conta pode gerenciar a cobrança.', - 'credits_exhausted' => 'Sem créditos de IA — você usou seus :limit créditos mensais. Faça upgrade do plano ou aguarde até o próximo mês.', + 'too_many_workspaces' => 'Você tem :count workspaces. Este plano inclui :limit — apague os extras antes de trocar.', 'subscription_required' => 'É necessária uma assinatura ativa para usar os recursos de IA.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Processando...', 'title' => 'Processando sua assinatura', 'description' => 'Aguarde enquanto configuramos sua conta. Isso levará apenas um momento.', - 'success_title' => 'Tudo pronto!', - 'success_description' => 'Sua assinatura está ativa. Redirecionando para seus workspaces...', - 'cancelled_title' => 'Pagamento cancelado', - 'cancelled_description' => 'Seu pagamento foi cancelado. Nenhuma cobrança foi realizada.', - 'retry' => 'Tentar novamente', ], ]; diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php deleted file mode 100644 index d55a848c9..000000000 --- a/lang/pt-BR/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Primeiros passos', - 'welcome' => 'Boas-vindas ao TryPost, :name', - 'description' => 'Siga os passos abaixo pra ver como o TryPost funciona e publicar seu primeiro post.', - 'skip_step' => 'Pular esta etapa', - 'continue' => 'Continuar no TryPost', - 'status' => [ - 'complete' => 'Concluído', - 'todo' => 'Pendente', - 'skipped' => 'Pulada', - ], - 'mcp' => [ - 'title' => 'Conecte seu assistente de IA', - 'description' => 'Adicione o TryPost como servidor MCP para o assistente criar e gerenciar posts por você.', - 'copied' => 'URL do MCP copiada.', - ], - 'social' => [ - 'title' => 'Conecte uma rede social', - 'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo.', - 'connected_elsewhere' => 'Você já conectou uma conta em outro workspace, então este passo está pronto.', - ], - 'first_post' => [ - 'title' => 'Crie seu primeiro post', - 'description' => 'Use este prompt no seu assistente, ou crie o post direto no TryPost.', - 'prompt_label' => 'Prompt de exemplo', - 'sample_prompt' => 'Crie um post social amigável apresentando minha marca e adapte para cada rede conectada.', - 'copy_prompt' => 'Copiar prompt', - 'copied' => 'Prompt de exemplo copiado.', - 'create_button' => 'Criar seu primeiro post', - 'or' => 'ou', - ], - 'ready' => [ - 'title' => 'Tudo pronto pra publicar', - 'description' => 'Você já pode seguir. Continue no TryPost e comece a planejar seu conteúdo.', - ], -]; diff --git a/lang/pt-BR/settings.php b/lang/pt-BR/settings.php index 1ec2a7936..9e63db793 100644 --- a/lang/pt-BR/settings.php +++ b/lang/pt-BR/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Conta', - 'usage' => 'Uso', 'billing' => 'Faturamento', ], 'title' => 'Configurações da conta', diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index fdf4f8810..76b2341aa 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Conta e cobrança', 'workspace_settings' => 'Configurações do workspace', 'log_out' => 'Sair', + 'help' => 'Ajuda', 'workspace' => [ 'connections' => 'Conexões', 'signatures' => 'Assinaturas', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analytics', 'repurposes' => 'Repost', - 'onboarding' => 'Primeiros passos', - 'onboarding_hint' => 'Complete a configuração', 'posts' => [ 'calendar' => 'Calendário', 'all' => 'Todos', @@ -42,8 +41,10 @@ 'archive_all' => 'Arquivar tudo', 'no_notifications' => 'Sem notificações', 'support' => [ + 'chat' => 'Suporte via chat', 'docs' => 'Documentação', 'referral' => 'Ganhe 30% de indicação', 'discord' => 'Comunidade Discord', + 'community' => 'Comunidade', ], ]; diff --git a/lang/pt-BR/usage.php b/lang/pt-BR/usage.php deleted file mode 100644 index cd49ec213..000000000 --- a/lang/pt-BR/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Uso', - - 'section_account' => 'Conta', - 'section_account_description' => 'Cotas e limites do seu plano.', - 'section_ai' => 'Créditos AI', - 'section_ai_description' => 'Os créditos são debitados conforme você usa os recursos de AI. Eles são renovados no dia 1 de cada mês.', - - 'workspaces' => 'Workspaces', - 'social_accounts' => 'Contas Sociais', - 'members' => 'Membros', - 'credits' => 'Créditos', -]; diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php index 6cca5b662..0917930d7 100644 --- a/lang/pt-BR/welcome.php +++ b/lang/pt-BR/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'O dono da sua conta é :name.', 'subscription_required_auto' => 'Esta página atualiza automaticamente — não precisa recarregar.', 'progress' => 'Progresso das boas-vindas', - 'go_to_step' => 'Ir para a etapa :step', - 'step_current' => 'Etapa :step (atual)', + 'step_of' => 'Etapa :step de :total', + 'back' => 'Voltar', + 'steps' => [ + 'persona' => 'Sobre você', + 'goals' => 'Seus objetivos', + 'referral_source' => 'Como nos encontrou', + 'connect' => 'Redes sociais', + 'plan' => 'Plano', + ], + 'preview' => [ + 'heading' => 'Seu workspace está tomando forma.', + 'pending' => 'Ainda não escolhido', + 'networks_empty' => 'Nenhuma rede conectada ainda', + ], 'personas' => [ 'creator' => 'Criador de conteúdo', 'freelancer' => 'Freelancer', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Economizar tempo postando em todas as redes de uma vez', 'ai_content' => 'Gerar posts com a IA do TryPost', - 'use_mcp' => 'Criar posts pelo Claude, ChatGPT ou Cursor', + 'use_mcp' => 'Criar posts pelo Claude, ChatGPT, etc.', 'plan_calendar' => 'Planejar meus posts num calendário', 'stay_on_brand' => 'Manter a consistência da minha marca', 'grow_audience' => 'Crescer minha audiência e engajamento', @@ -38,6 +50,8 @@ 'just_exploring' => 'Só dando uma olhada por enquanto', 'other' => 'Outra coisa', ], + 'plan_title' => 'Escolha seu plano', + 'plan_description' => 'Os dois planos têm os mesmos recursos. O que muda é o número de workspaces, e você pode trocar de plano quando quiser.', 'referral_source_title' => 'Como você nos encontrou?', 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', 'referral_source' => [ diff --git a/lang/pt-BR/workspaces.php b/lang/pt-BR/workspaces.php index 403d7e4f7..cf590e644 100644 --- a/lang/pt-BR/workspaces.php +++ b/lang/pt-BR/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Atual', 'connections' => ':count conexões', 'posts' => ':count posts', + 'subscription_required' => 'Assine para criar mais workspaces.', + 'limit_reached' => 'Seu plano inclui um workspace. Faça upgrade para adicionar mais.', + + 'upgrade_dialog' => [ + 'title' => 'Faça upgrade para criar mais workspaces', + 'description' => 'Seu plano inclui um workspace. Faça upgrade para Workspaces e tenha workspaces ilimitados — um para cada marca ou cliente.', + ], 'create' => [ 'page_title' => 'Crie seu workspace', diff --git a/lang/ru/accounts.php b/lang/ru/accounts.php index 577bc5fdd..c237d979e 100644 --- a/lang/ru/accounts.php +++ b/lang/ru/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Обзор всех подключённых социальных аккаунтов', 'connect_cta' => 'Подключить', 'connect_another' => 'Подключить ещё', + 'actions' => 'Действия с аккаунтом', + 'activate' => 'Включить', + 'deactivate' => 'Отключить', + 'active' => 'Активен', + 'paused' => 'Отключён', + 'accounts_count' => ':count аккаунт|:count аккаунта|:count аккаунтов', + + 'variants' => [ + 'linkedin-page' => 'Страница', + 'instagram-facebook' => 'через Facebook', + ], 'not_connected' => 'Не подключено', 'connect' => 'Подключить', diff --git a/lang/ru/billing.php b/lang/ru/billing.php index c6e7d9255..d168bde50 100644 --- a/lang/ru/billing.php +++ b/lang/ru/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Обновить оплату', ], - 'annual_banner' => [ - 'title' => 'Получите 2 месяца бесплатно', - 'description' => 'Перейдите на годовую оплату и платите меньше каждый месяц — тот же тариф, ничего больше не меняется.', - 'cta' => 'Перейти на годовую оплату', - ], - 'subscribe' => [ 'billed_monthly' => 'Ежемесячная оплата', - 'billed_yearly' => 'Годовая оплата', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Планы', + 'description' => 'Повышайте или понижайте план в любое время.', + 'monthly' => 'Ежемесячно', + 'yearly' => 'Ежегодно', + 'save_two_months' => '2 месяца бесплатно', + 'per_month' => '/месяц', + 'workspaces_one' => 'Один workspace', + 'workspaces_unlimited' => 'Неограниченное число workspace', + 'workspaces_tooltip' => 'Workspace — это один бренд или клиент, отделённый от остальных: свои соцсети, подписи, метки, аналитика, права участников и подключение MCP.', + 'current' => 'Текущий план', + 'switch_to_yearly' => 'Перейти на годовой', + 'switch_to_monthly' => 'Перейти на месячный', + 'select' => 'Выбрать :plan', + 'upgrade' => 'Перейти на :plan', + 'downgrade' => 'Понизить до :plan', + 'start_first_month' => 'Начать за :price', + 'per_first_month' => '/первый месяц', + 'then_monthly' => 'Затем :price/мес.', + 'billed_yearly_total' => 'Оплата раз в год · :price (2 месяца бесплатно)', + 'socials_tagline' => 'Для авторов и небольших брендов.', + 'workspaces_tagline' => 'Для агентств и крупного бизнеса.', + 'everything_included' => 'Всё включено', + 'features' => [ + 'networks_all' => 'Все соцсети включены', + 'networks_all_tooltip' => 'Вы можете публиковать во всех этих соцсетях.', + 'accounts_unlimited' => 'Безлимитные аккаунты', + 'accounts_unlimited_tooltip' => 'Подключайте сколько угодно аккаунтов, даже несколько из одной соцсети. Например, три Instagram.', + 'calendar' => 'Календарь: месяц, неделя и день', + 'calendar_tooltip' => 'Весь месяц на одном экране: что запланировано, поставлено в очередь и уже опубликовано. Переключайтесь на неделю или день, когда нужны детали.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Ваш ИИ-ассистент для написания и редактирования постов.', + 'mcp' => 'MCP: публикуйте из Claude, ChatGPT или Grok', + 'mcp_tooltip' => 'Подключите Claude, ChatGPT или Grok к своему workspace. Попросите создавать и планировать посты, получать метрики, находить самые успешные посты и планировать следующий контент на основе ваших данных.', + 'repurpose' => 'Repurpose: из одного поста сделайте много', + 'repurpose_tooltip' => 'Выберите исходный аккаунт. Каждый новый пост, который вы там публикуете, автоматически публикуется в остальных ваших сетях. Открывать TryPost не нужно.', + 'analytics' => 'Аналитика', + 'analytics_tooltip' => 'Получайте метрики вроде показов, охвата, лайков и комментариев по каждому посту и аккаунту, всё в одном месте.', + 'team' => 'Неограниченное число участников', + 'team_tooltip' => 'Приглашайте в команду сколько угодно людей без доплаты. Настройте, что может делать каждый, и утверждайте посты перед публикацией.', ], ], 'plan' => [ - 'title' => 'Тариф', - 'description' => 'Управляйте своим тарифом подписки.', - 'label' => 'Тариф', - 'workspaces' => '{1}:count рабочее пространство|[2,4]:count рабочих пространства|[5,*]:count рабочих пространств', - 'per_workspace' => 'за рабочее пространство', - 'price' => 'Цена', - 'month' => 'месяц', 'trial' => 'Пробный период', - 'active' => 'Активна', - 'past_due' => 'Просрочена', 'cancelling' => 'Отменяется', 'trial_ends' => 'Пробный период заканчивается', ], 'subscription' => [ - 'title' => 'Подписка', - 'description' => 'Управляйте способом оплаты, платёжными данными и подпиской.', - 'payment_method' => 'Способ оплаты', + 'title' => 'Способ оплаты', + 'description' => 'Обновите карту или платёжные данные в Stripe.', 'no_payment_method' => 'Способ оплаты пока не добавлен.', 'expires_on' => 'Истекает :month/:year', - 'manage_label' => 'Подписка', 'manage_stripe' => 'Управлять в Stripe', ], 'invoices' => [ 'title' => 'Счета', 'description' => 'Скачивайте прошлые счета.', - 'empty' => 'Счета не найдены', 'paid' => 'Оплачено', ], 'flash' => [ 'plan_changed' => 'Вы перешли на тариф :plan.', - 'switched_to_yearly' => 'Вы перешли на годовую оплату.', 'cannot_manage' => 'Управлять оплатой может только владелец аккаунта.', - 'credits_exhausted' => 'Кредиты ИИ закончились — ваш месячный лимит :limit исчерпан. Повысьте тариф или подождите до следующего месяца.', + 'too_many_workspaces' => 'У вас :count workspace. Этот план включает :limit — удалите лишние перед сменой.', 'subscription_required' => 'Для использования функций ИИ требуется активная подписка.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Обработка...', 'title' => 'Обрабатываем вашу подписку', 'description' => 'Подождите, пока мы настраиваем ваш аккаунт. Это займёт всего мгновение.', - 'success_title' => 'Всё готово!', - 'success_description' => 'Ваша подписка активна. Перенаправляем вас к рабочим пространствам...', - 'cancelled_title' => 'Оформление отменено', - 'cancelled_description' => 'Оформление отменено. Списаний не было.', - 'retry' => 'Попробовать снова', ], ]; diff --git a/lang/ru/onboarding.php b/lang/ru/onboarding.php deleted file mode 100644 index 35d1c763e..000000000 --- a/lang/ru/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Начало работы', - 'welcome' => 'Добро пожаловать в TryPost, :name', - 'description' => 'Выполните шаги ниже, чтобы увидеть, как работает TryPost, и опубликовать первый пост.', - 'skip_step' => 'Пропустить этот шаг', - 'continue' => 'Перейти в TryPost', - 'status' => [ - 'complete' => 'Готово', - 'todo' => 'Сделать', - 'skipped' => 'Пропущено', - ], - 'mcp' => [ - 'title' => 'Подключите ИИ-ассистента', - 'description' => 'Добавьте TryPost как MCP-сервер, чтобы ассистент мог создавать и вести соцпосты за вас.', - 'copied' => 'URL MCP скопирован.', - ], - 'social' => [ - 'title' => 'Подключите соцсеть', - 'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.', - 'connected_elsewhere' => 'Вы уже подключили аккаунт в другом пространстве, так что этот шаг выполнен.', - ], - 'first_post' => [ - 'title' => 'Создайте первый пост', - 'description' => 'Попробуйте этот стартовый промпт с подключённым ассистентом или создайте пост прямо в TryPost.', - 'prompt_label' => 'Пример промпта', - 'sample_prompt' => 'Создай дружелюбный соцпост с представлением моего бренда и адаптируй его для каждой подключённой сети.', - 'copy_prompt' => 'Копировать промпт', - 'copied' => 'Пример промпта скопирован.', - 'create_button' => 'Создать первый пост', - 'or' => 'или', - ], - 'ready' => [ - 'title' => 'Вы готовы публиковать', - 'description' => 'Всё настроено. Перейдите в TryPost и начните планировать контент.', - ], -]; diff --git a/lang/ru/settings.php b/lang/ru/settings.php index 79cf9d15f..2db4a9b74 100644 --- a/lang/ru/settings.php +++ b/lang/ru/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Аккаунт', - 'usage' => 'Использование', 'billing' => 'Оплата', ], 'title' => 'Настройки аккаунта', diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index 45b5ce970..2c4aac96e 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Аккаунт и оплата', 'workspace_settings' => 'Настройки workspace', 'log_out' => 'Выйти', + 'help' => 'Помощь', 'workspace' => [ 'connections' => 'Подключения', 'signatures' => 'Подписи', @@ -27,8 +28,6 @@ ], 'analytics' => 'Аналитика', 'repurposes' => 'Repurpose', - 'onboarding' => 'Начало работы', - 'onboarding_hint' => 'Завершите настройку', 'posts' => [ 'calendar' => 'Календарь', 'all' => 'Все', @@ -42,8 +41,10 @@ 'archive_all' => 'Архивировать все', 'no_notifications' => 'Нет уведомлений', 'support' => [ + 'chat' => 'Поддержка в чате', 'docs' => 'Документация', 'referral' => 'Зарабатывайте 30% по реферальной программе', 'discord' => 'Сообщество Discord', + 'community' => 'Сообщество', ], ]; diff --git a/lang/ru/usage.php b/lang/ru/usage.php deleted file mode 100644 index d0f41c91b..000000000 --- a/lang/ru/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Использование', - - 'section_account' => 'Аккаунт', - 'section_account_description' => 'Квоты и лимиты вашего тарифа.', - 'section_ai' => 'Кредиты ИИ', - 'section_ai_description' => 'Кредиты списываются по мере использования функций ИИ. Они обнуляются первого числа каждого месяца.', - - 'workspaces' => 'Рабочие пространства', - 'social_accounts' => 'Социальные аккаунты', - 'members' => 'Участники', - 'credits' => 'Кредиты', -]; diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php index 4dad73b90..5ff37bbdf 100644 --- a/lang/ru/welcome.php +++ b/lang/ru/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Владелец вашего аккаунта — :name.', 'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.', 'progress' => 'Прогресс приветствия', - 'go_to_step' => 'Перейти к шагу :step', - 'step_current' => 'Шаг :step (текущий)', + 'step_of' => 'Шаг :step из :total', + 'back' => 'Назад', + 'steps' => [ + 'persona' => 'О вас', + 'goals' => 'Ваши цели', + 'referral_source' => 'Как вы нас нашли', + 'connect' => 'Соцсети', + 'plan' => 'Тариф', + ], + 'preview' => [ + 'heading' => 'Ваш workspace обретает форму.', + 'pending' => 'Ещё не выбрано', + 'networks_empty' => 'Соцсети пока не подключены', + ], 'personas' => [ 'creator' => 'Автор контента', 'freelancer' => 'Фрилансер', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Экономить время, публикуя всюду сразу', 'ai_content' => 'Генерировать посты с ИИ TryPost', - 'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor', + 'use_mcp' => 'Создавать посты через Claude, ChatGPT и др.', 'plan_calendar' => 'Планировать посты в календаре', 'stay_on_brand' => 'Держать каждый пост в стиле бренда', 'grow_audience' => 'Наращивать аудиторию и вовлечённость', @@ -38,6 +50,8 @@ 'just_exploring' => 'Пока просто знакомлюсь', 'other' => 'Что-то ещё', ], + 'plan_title' => 'Выберите тариф', + 'plan_description' => 'У обоих планов одинаковые функции. Разница в количестве workspace, и сменить план можно в любой момент.', 'referral_source_title' => 'Как вы нас нашли?', 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', 'referral_source' => [ diff --git a/lang/ru/workspaces.php b/lang/ru/workspaces.php index 7e1cb4b92..4908e7f73 100644 --- a/lang/ru/workspaces.php +++ b/lang/ru/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Текущее', 'connections' => ':count подключений', 'posts' => ':count постов', + 'subscription_required' => 'Оформите подписку, чтобы создавать больше workspace.', + 'limit_reached' => 'В вашем плане один workspace. Перейдите на другой план, чтобы добавить больше.', + + 'upgrade_dialog' => [ + 'title' => 'Обновите план, чтобы добавить workspace', + 'description' => 'В вашем плане один workspace. Перейдите на Workspaces для неограниченного числа workspace — по одному на бренд или клиента.', + ], 'create' => [ 'page_title' => 'Создайте рабочее пространство', diff --git a/lang/tr/accounts.php b/lang/tr/accounts.php index b56a996de..10e42ccd6 100644 --- a/lang/tr/accounts.php +++ b/lang/tr/accounts.php @@ -8,6 +8,17 @@ 'description' => 'Bağlı tüm sosyal hesaplarınıza genel bakış', 'connect_cta' => 'Bağla', 'connect_another' => 'Başka birini bağla', + 'actions' => 'Hesap işlemleri', + 'activate' => 'Aç', + 'deactivate' => 'Kapat', + 'active' => 'Aktif', + 'paused' => 'Kapalı', + 'accounts_count' => ':count hesap|:count hesap', + + 'variants' => [ + 'linkedin-page' => 'Sayfa', + 'instagram-facebook' => 'Facebook üzerinden', + ], 'not_connected' => 'Bağlı değil', 'connect' => 'Bağla', diff --git a/lang/tr/billing.php b/lang/tr/billing.php index e50e65562..bede5b667 100644 --- a/lang/tr/billing.php +++ b/lang/tr/billing.php @@ -11,57 +11,82 @@ 'cta' => 'Ödemeyi güncelle', ], - 'annual_banner' => [ - 'title' => '2 ay ücretsiz kazanın', - 'description' => 'Yıllık faturalandırmaya geçin ve her ay daha az ödeyin — aynı plan, başka hiçbir şey değişmez.', - 'cta' => 'Yıllığa yükselt', - ], - 'subscribe' => [ 'billed_monthly' => 'Aylık faturalandırılır', - 'billed_yearly' => 'Yıllık faturalandırılır', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Planlar', + 'description' => 'İstediğiniz zaman yükseltin veya düşürün.', + 'monthly' => 'Aylık', + 'yearly' => 'Yıllık', + 'save_two_months' => '2 ay ücretsiz', + 'per_month' => '/ay', + '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.', + 'current' => 'Mevcut plan', + 'switch_to_yearly' => 'Yıllığa geç', + 'switch_to_monthly' => 'Aylığa geç', + 'select' => ':plan seç', + 'upgrade' => ':plan’e yükselt', + 'downgrade' => ':plan’e düşür', + 'start_first_month' => ':price ile başla', + 'per_first_month' => '/ilk ay', + 'then_monthly' => 'Sonra :price/ay', + 'billed_yearly_total' => 'Yıllık faturalandırılır · :price (2 ay bedava)', + 'socials_tagline' => 'İçerik üreticileri ve küçük markalar için.', + 'workspaces_tagline' => 'Ajanslar ve büyük işletmeler için.', + 'everything_included' => 'Her şey dahil', + 'features' => [ + 'networks_all' => 'Tüm sosyal ağlar dahil', + 'networks_all_tooltip' => 'Bu ağların hepsinde paylaşım yapabilirsiniz.', + 'accounts_unlimited' => 'Sınırsız sosyal hesaplar', + 'accounts_unlimited_tooltip' => 'İstediğiniz kadar hesap bağlayın, aynı ağdan birkaç tane bile. Örneğin üç Instagram.', + 'calendar' => 'Takvim: aylık, haftalık ve günlük görünüm', + 'calendar_tooltip' => 'Tüm ayınızı tek bakışta görün: planlanan, zamanlanan ve yayımlanmış olanlar. Ayrıntı gerektiğinde hafta veya gün görünümüne geçin.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Gönderi yazmak ve gözden geçirmek için yapay zeka asistanınız.', + 'mcp' => 'MCP: Claude, ChatGPT veya Grok ile paylaşın', + 'mcp_tooltip' => 'Claude, ChatGPT veya Grok\'u workspace\'inize bağlayın. Gönderi oluşturup planlamasını, metrikleri çekmesini, en iyi performans göstereni bulmasını ve kendi verilerinizle sonraki içeriği planlamasını isteyin.', + 'repurpose' => 'Repurpose: bir gönderiyi çoğaltın', + 'repurpose_tooltip' => 'Bir kaynak hesap seçin. Orada yayımladığınız her yeni gönderi, diğer ağlarınızda otomatik olarak yeniden yayımlanır. TryPost\'u açmanız gerekmez.', + 'analytics' => 'Analytics', + 'analytics_tooltip' => 'Gösterim, erişim, beğeni ve yorum gibi metrikleri her gönderi ve her hesap için tek bir yerde alın.', + 'team' => 'Sınırsız üye', + 'team_tooltip' => 'Ekibinize istediğiniz kadar kişi davet edin, ek ücret yok. Her kişinin ne yapabileceğini belirleyin ve gönderileri yayından önce onaylayın.', ], ], 'plan' => [ - 'title' => 'Plan', - 'description' => 'Abonelik planınızı yönetin.', - 'label' => 'Plan', - 'workspaces' => '{1}:count çalışma alanı|[2,*]:count çalışma alanı', - 'per_workspace' => 'çalışma alanı başına', - 'price' => 'Fiyat', - 'month' => 'ay', 'trial' => 'Deneme', - 'active' => 'Etkin', - 'past_due' => 'Gecikmiş', 'cancelling' => 'İptal ediliyor', 'trial_ends' => 'Deneme bitişi', ], 'subscription' => [ - 'title' => 'Abonelik', - 'description' => 'Ödeme yönteminizi, faturalandırma bilgilerinizi ve aboneliğinizi yönetin.', - 'payment_method' => 'Ödeme yöntemi', + 'title' => 'Ödeme yöntemi', + '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_label' => 'Abonelik', 'manage_stripe' => 'Stripe\'ta yönet', ], 'invoices' => [ 'title' => 'Faturalar', 'description' => 'Geçmiş faturalarınızı indirin.', - 'empty' => 'Fatura bulunamadı', 'paid' => 'Ödendi', ], 'flash' => [ 'plan_changed' => 'Artık :plan planındasınız.', - 'switched_to_yearly' => 'Artık yıllık faturalandırmadasınız.', 'cannot_manage' => 'Faturalandırmayı yalnızca hesap sahibi yönetebilir.', - 'credits_exhausted' => 'AI kredileriniz bitti — aylık :limit hakkınız kullanıldı. Planınızı yükseltin veya gelecek ayı bekleyin.', + 'too_many_workspaces' => ':count workspace\'iniz var. Bu plan :limit içeriyor — geçmeden önce fazlaları silin.', 'subscription_required' => 'AI özelliklerini kullanmak için etkin bir abonelik gereklidir.', ], @@ -69,10 +94,5 @@ 'page_title' => 'İşleniyor...', 'title' => 'Aboneliğiniz işleniyor', 'description' => 'Hesabınızı ayarlarken lütfen bekleyin. Bu yalnızca bir an sürecek.', - 'success_title' => 'Her şey hazır!', - 'success_description' => 'Aboneliğiniz etkin. Çalışma alanlarınıza yönlendiriliyorsunuz...', - 'cancelled_title' => 'Ödeme iptal edildi', - 'cancelled_description' => 'Ödemeniz iptal edildi. Herhangi bir ücret alınmadı.', - 'retry' => 'Tekrar dene', ], ]; diff --git a/lang/tr/onboarding.php b/lang/tr/onboarding.php deleted file mode 100644 index 37906266f..000000000 --- a/lang/tr/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Başlarken', - 'welcome' => 'TryPost’a hoş geldin, :name', - 'description' => 'TryPost’un nasıl çalıştığını görmek ve ilk gönderini yayınlamak için aşağıdaki adımları izle.', - 'skip_step' => 'Bu adımı atla', - 'continue' => 'TryPost’a devam et', - 'status' => [ - 'complete' => 'Tamamlandı', - 'todo' => 'Yapılacak', - 'skipped' => 'Atlandı', - ], - 'mcp' => [ - 'title' => 'AI asistanını bağla', - 'description' => 'Asistanının senin için sosyal gönderiler oluşturup yönetebilmesi için TryPost’u MCP sunucusu olarak ekle.', - 'copied' => 'MCP URL’si kopyalandı.', - ], - 'social' => [ - 'title' => 'Bir sosyal hesap bağla', - 'description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç.', - 'connected_elsewhere' => 'Başka bir çalışma alanında zaten bir hesap bağladın, bu adım tamam.', - ], - 'first_post' => [ - 'title' => 'İlk gönderini oluştur', - 'description' => 'Bu başlangıç prompt’unu bağlı asistanınla dene veya gönderiyi doğrudan TryPost’ta oluştur.', - 'prompt_label' => 'Örnek prompt', - 'sample_prompt' => 'Markamı tanıtan samimi bir sosyal gönderi oluştur ve bağlı her ağ için uyarla.', - 'copy_prompt' => 'Prompt’u kopyala', - 'copied' => 'Örnek prompt kopyalandı.', - 'create_button' => 'İlk gönderini oluştur', - 'or' => 'veya', - ], - 'ready' => [ - 'title' => 'Yayınlamaya hazırsın', - 'description' => 'Her şey hazır. TryPost’a devam et ve içeriğini planlamaya başla.', - ], -]; diff --git a/lang/tr/settings.php b/lang/tr/settings.php index f2852da05..afb1249cb 100644 --- a/lang/tr/settings.php +++ b/lang/tr/settings.php @@ -313,7 +313,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Hesap', - 'usage' => 'Kullanım', 'billing' => 'Faturalandırma', ], 'title' => 'Hesap Ayarları', diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index 6769e4eb7..005803555 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Hesap ve faturalandırma', 'workspace_settings' => 'Çalışma alanı ayarları', 'log_out' => 'Çıkış yap', + 'help' => 'Yardım', 'workspace' => [ 'connections' => 'Bağlantılar', 'signatures' => 'İmzalar', @@ -27,8 +28,6 @@ ], 'analytics' => 'Analitik', 'repurposes' => 'Repurpose', - 'onboarding' => 'Başlarken', - 'onboarding_hint' => 'Kurulumu bitir', 'posts' => [ 'calendar' => 'Takvim', 'all' => 'Tümü', @@ -42,8 +41,10 @@ 'archive_all' => 'Tümünü arşivle', 'no_notifications' => 'Bildirim yok', 'support' => [ + 'chat' => 'Sohbet desteği', 'docs' => 'Dokümantasyon', 'referral' => '%30 referans kazanın', 'discord' => 'Discord topluluğu', + 'community' => 'Topluluk', ], ]; diff --git a/lang/tr/usage.php b/lang/tr/usage.php deleted file mode 100644 index a707da162..000000000 --- a/lang/tr/usage.php +++ /dev/null @@ -1,17 +0,0 @@ - 'Kullanım', - - 'section_account' => 'Hesap', - 'section_account_description' => 'Planınız için kotalar ve limitler.', - 'section_ai' => 'AI Kredileri', - 'section_ai_description' => 'AI özellikleri kullanıldıkça krediler düşülür. Her ayın ilk günü sıfırlanır.', - - 'workspaces' => 'Çalışma Alanları', - 'social_accounts' => 'Sosyal Hesaplar', - 'members' => 'Üyeler', - 'credits' => 'Krediler', -]; diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php index 9d603e60b..81739b356 100644 --- a/lang/tr/welcome.php +++ b/lang/tr/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Hesap sahibiniz :name.', 'subscription_required_auto' => 'Bu sayfa otomatik olarak güncellenir — yenilemenize gerek yok.', 'progress' => 'Karşılama ilerlemesi', - 'go_to_step' => ':step. adıma git', - 'step_current' => 'Adım :step (şu anki)', + 'step_of' => 'Adım :step / :total', + 'back' => 'Geri', + 'steps' => [ + 'persona' => 'Senin hakkında', + 'goals' => 'Hedeflerin', + 'referral_source' => 'Bizi nasıl buldun', + 'connect' => 'Sosyal ağlar', + 'plan' => 'Plan', + ], + 'preview' => [ + 'heading' => 'Workspace’in şekilleniyor.', + 'pending' => 'Henüz seçilmedi', + 'networks_empty' => 'Henüz bağlı ağ yok', + ], 'personas' => [ 'creator' => 'İçerik üreticisi', 'freelancer' => 'Serbest çalışan', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Her yere aynı anda paylaşarak zaman kazanmak', 'ai_content' => 'TryPost AI ile gönderi oluşturmak', - 'use_mcp' => 'Claude, ChatGPT veya Cursor ile gönderi oluşturmak', + 'use_mcp' => 'Claude, ChatGPT vb. ile gönderi oluşturmak', 'plan_calendar' => 'Gönderilerimi bir takvimde planlamak', 'stay_on_brand' => 'Her gönderiyi marka çizgisinde tutmak', 'grow_audience' => 'Kitlemi ve etkileşimimi büyütmek', @@ -38,6 +50,8 @@ 'just_exploring' => 'Şimdilik sadece keşfetmek', 'other' => 'Başka bir şey', ], + 'plan_title' => 'Planını seç', + 'plan_description' => 'İki planın özellikleri aynı. Fark, workspace sayısında; planı istediğiniz zaman değiştirebilirsiniz.', 'referral_source_title' => 'Bizi nasıl buldunuz?', 'referral_source_description' => 'İnsanların TryPost\'u nasıl keşfettiğini anlamamıza yardımcı olur.', 'referral_source' => [ diff --git a/lang/tr/workspaces.php b/lang/tr/workspaces.php index 5a97bcafc..8757d4cd4 100644 --- a/lang/tr/workspaces.php +++ b/lang/tr/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Geçerli', 'connections' => ':count bağlantı', 'posts' => ':count gönderi', + 'subscription_required' => 'Daha fazla workspace oluşturmak için abone olun.', + 'limit_reached' => 'Planınız bir workspace içerir. Daha fazlasını eklemek için yükseltin.', + + 'upgrade_dialog' => [ + 'title' => 'Daha fazla workspace eklemek için yükseltin', + 'description' => 'Planınız bir workspace içerir. Sınırsız workspace için Workspaces’e yükseltin — her marka veya müşteri için bir tane.', + ], 'create' => [ 'page_title' => 'Çalışma alanınızı oluşturun', diff --git a/lang/uk/accounts.php b/lang/uk/accounts.php index f4e05888a..464aaae51 100644 --- a/lang/uk/accounts.php +++ b/lang/uk/accounts.php @@ -6,6 +6,17 @@ 'description' => 'Огляд усіх підключених соціальних акаунтів', 'connect_cta' => 'Підключити', 'connect_another' => 'Підключити ще', + 'actions' => 'Дії з акаунтом', + 'activate' => 'Увімкнути', + 'deactivate' => 'Вимкнути', + 'active' => 'Активний', + 'paused' => 'Вимкнений', + 'accounts_count' => ':count акаунт|:count акаунти|:count акаунтів', + + 'variants' => [ + 'linkedin-page' => 'Сторінка', + 'instagram-facebook' => 'через Facebook', + ], 'not_connected' => 'Не підключено', 'connect' => 'Підключити', diff --git a/lang/uk/billing.php b/lang/uk/billing.php index 055302ffd..c577acbb4 100644 --- a/lang/uk/billing.php +++ b/lang/uk/billing.php @@ -9,57 +9,82 @@ 'cta' => 'Оновити оплату', ], - 'annual_banner' => [ - 'title' => '2 місяці безкоштовно', - 'description' => 'Перейдіть на річну оплату та платіть менше щомісяця — той самий план, нічого більше не змінюється.', - 'cta' => 'Перейти на річну оплату', - ], - 'subscribe' => [ 'billed_monthly' => 'Щомісячна оплата', - 'billed_yearly' => 'Річна оплата', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], + ], + ], + + 'plans' => [ + 'title' => 'Плани', + 'description' => 'Підвищуйте або знижуйте план будь-коли.', + 'monthly' => 'Щомісяця', + 'yearly' => 'Щороку', + 'save_two_months' => '2 місяці безкоштовно', + 'per_month' => '/місяць', + 'workspaces_one' => 'Один workspace', + 'workspaces_unlimited' => 'Необмежена кількість workspace', + 'workspaces_tooltip' => 'Workspace — це один бренд або клієнт, відокремлений від інших: власні соцмережі, підписи, мітки, аналітика, права учасників і підключення MCP.', + 'current' => 'Поточний план', + 'switch_to_yearly' => 'Перейти на річний', + 'switch_to_monthly' => 'Перейти на місячний', + 'select' => 'Обрати :plan', + 'upgrade' => 'Перейти на :plan', + 'downgrade' => 'Понизити до :plan', + 'start_first_month' => 'Почати за :price', + 'per_first_month' => '/перший місяць', + 'then_monthly' => 'Потім :price/міс.', + 'billed_yearly_total' => 'Оплата раз на рік · :price (2 місяці безкоштовно)', + 'socials_tagline' => 'Для авторів і невеликих брендів.', + 'workspaces_tagline' => 'Для агентств і великого бізнесу.', + 'everything_included' => 'Усе включено', + 'features' => [ + 'networks_all' => 'Усі соцмережі включено', + 'networks_all_tooltip' => 'Ви можете публікувати в усіх цих соцмережах.', + 'accounts_unlimited' => 'Необмежені акаунти', + 'accounts_unlimited_tooltip' => 'Підключайте скільки завгодно акаунтів, навіть кілька з однієї соцмережі. Наприклад, три Instagram.', + 'calendar' => 'Календар: місяць, тиждень і день', + 'calendar_tooltip' => 'Весь місяць на одному екрані: що заплановано, поставлено в чергу та вже опубліковано. Перемикайтеся на тиждень або день, коли потрібні деталі.', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => 'Ваш ШІ-асистент для написання й редагування постів.', + 'mcp' => 'MCP: публікуйте з Claude, ChatGPT або Grok', + 'mcp_tooltip' => 'Підключіть Claude, ChatGPT або Grok до свого workspace. Попросіть створювати й планувати пости, отримувати метрики, знаходити найуспішніші пости та планувати наступний контент на основі ваших даних.', + 'repurpose' => 'Repurpose: з одного поста зробіть багато', + 'repurpose_tooltip' => 'Оберіть вихідний акаунт. Кожен новий пост, який ви там публікуєте, автоматично публікується в інших ваших мережах. Відкривати TryPost не потрібно.', + 'analytics' => 'Аналітика', + 'analytics_tooltip' => 'Отримуйте метрики, як покази, охоплення, лайки та коментарі, за кожним постом і акаунтом, усе в одному місці.', + 'team' => 'Необмежена кількість учасників', + 'team_tooltip' => 'Запрошуйте в команду скільки завгодно людей без доплати. Налаштуйте, що може робити кожен, і затверджуйте пости перед публікацією.', ], ], 'plan' => [ - 'title' => 'План', - 'description' => 'Керуйте своїм тарифним планом.', - 'label' => 'План', - 'workspaces' => '{1}:count робочий простір|[2,*]:count робочих просторів', - 'per_workspace' => 'за робочий простір', - 'price' => 'Ціна', - 'month' => 'місяць', 'trial' => 'Пробний період', - 'active' => 'Активний', - 'past_due' => 'Прострочено', 'cancelling' => 'Скасовується', 'trial_ends' => 'Пробний період закінчується', ], 'subscription' => [ - 'title' => 'Підписка', - 'description' => 'Керуйте способом оплати, реквізитами та підпискою.', - 'payment_method' => 'Спосіб оплати', + 'title' => 'Спосіб оплати', + 'description' => 'Оновіть картку або платіжні дані в Stripe.', 'no_payment_method' => 'Спосіб оплати ще не додано.', 'expires_on' => 'Діє до :month/:year', - 'manage_label' => 'Підписка', 'manage_stripe' => 'Керувати в Stripe', ], 'invoices' => [ 'title' => 'Рахунки', 'description' => 'Завантажуйте минулі рахунки.', - 'empty' => 'Рахунків не знайдено', 'paid' => 'Оплачено', ], 'flash' => [ 'plan_changed' => 'Ви перейшли на план :plan.', - 'switched_to_yearly' => 'Тепер у вас річна оплата.', 'cannot_manage' => 'Лише власник облікового запису може керувати оплатою.', - 'credits_exhausted' => 'AI-кредити вичерпано — ваш місячний ліміт :limit використано. Оновіть план або зачекайте до наступного місяця.', + 'too_many_workspaces' => 'У вас :count workspace. Цей план включає :limit — видаліть зайві перед зміною.', 'subscription_required' => 'Для використання AI-функцій потрібна активна підписка.', ], @@ -67,10 +92,5 @@ 'page_title' => 'Обробка...', 'title' => 'Обробка вашої підписки', 'description' => 'Зачекайте, поки ми налаштуємо ваш обліковий запис. Це займе лише мить.', - 'success_title' => 'Усе готово!', - 'success_description' => 'Ваша підписка активна. Перенаправляємо до ваших робочих просторів...', - 'cancelled_title' => 'Оформлення скасовано', - 'cancelled_description' => 'Оформлення підписки скасовано. Кошти не списано.', - 'retry' => 'Спробувати ще раз', ], ]; diff --git a/lang/uk/onboarding.php b/lang/uk/onboarding.php deleted file mode 100644 index abc5e1b2e..000000000 --- a/lang/uk/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - 'Початок роботи', - 'welcome' => 'Ласкаво просимо до TryPost, :name', - 'description' => 'Виконайте кроки нижче, щоб зрозуміти, як працює TryPost, і опублікувати свій перший пост.', - 'skip_step' => 'Пропустити цей крок', - 'continue' => 'Продовжити до TryPost', - 'status' => [ - 'complete' => 'Готово', - 'todo' => 'До виконання', - 'skipped' => 'Пропущено', - ], - 'mcp' => [ - 'title' => 'Підключіть свого AI-асистента', - 'description' => 'Додайте TryPost як MCP-сервер, щоб асистент міг створювати й керувати соцмережевими постами за вас.', - 'copied' => 'MCP URL скопійовано.', - ], - 'social' => [ - 'title' => 'Підключіть соціальний акаунт', - 'description' => 'Оберіть принаймні одну мережу, у якій TryPost зможе публікувати ваш контент.', - 'connected_elsewhere' => 'Ви вже підключили акаунт в іншому робочому просторі, тож цей крок виконано.', - ], - 'first_post' => [ - 'title' => 'Створіть свій перший пост', - 'description' => 'Спробуйте цей стартовий промпт із підключеним асистентом або створіть пост безпосередньо в TryPost.', - 'prompt_label' => 'Приклад промпту', - 'sample_prompt' => 'Створи дружній пост, що представляє мій бренд, і адаптуй його для кожної підключеної мережі.', - 'copy_prompt' => 'Скопіювати промпт', - 'copied' => 'Приклад промпту скопійовано.', - 'create_button' => 'Створити перший пост', - 'or' => 'або', - ], - 'ready' => [ - 'title' => 'Ви готові публікувати', - 'description' => 'Усе налаштовано. Продовжуйте в TryPost і починайте планувати контент.', - ], -]; diff --git a/lang/uk/settings.php b/lang/uk/settings.php index b53ab183b..94eed5195 100644 --- a/lang/uk/settings.php +++ b/lang/uk/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => 'Обліковий запис', - 'usage' => 'Використання', 'billing' => 'Оплата', ], 'title' => 'Налаштування облікового запису', diff --git a/lang/uk/sidebar.php b/lang/uk/sidebar.php index f7ded40b1..da58af5e5 100644 --- a/lang/uk/sidebar.php +++ b/lang/uk/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => 'Акаунт і оплата', 'workspace_settings' => 'Налаштування робочого простору', 'log_out' => 'Вийти', + 'help' => 'Допомога', 'workspace' => [ 'connections' => 'Підключення', 'signatures' => 'Підписи', @@ -27,8 +28,6 @@ ], 'analytics' => 'Аналітика', 'repurposes' => 'Repurpose', - 'onboarding' => 'Початок роботи', - 'onboarding_hint' => 'Завершіть налаштування', 'posts' => [ 'calendar' => 'Календар', 'all' => 'Усі', @@ -42,8 +41,10 @@ 'archive_all' => 'Архівувати все', 'no_notifications' => 'Немає сповіщень', 'support' => [ + 'chat' => 'Підтримка в чаті', 'docs' => 'Документація', 'referral' => 'Отримуйте 30% за рефералами', 'discord' => 'Спільнота Discord', + 'community' => 'Спільнота', ], ]; diff --git a/lang/uk/usage.php b/lang/uk/usage.php deleted file mode 100644 index 39bf104f7..000000000 --- a/lang/uk/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - 'Використання', - - 'section_account' => 'Обліковий запис', - 'section_account_description' => 'Квоти та обмеження вашого тарифного плану.', - 'section_ai' => 'AI-кредити', - 'section_ai_description' => 'Кредити списуються під час використання AI-функцій. Вони оновлюються першого числа кожного місяця.', - - 'workspaces' => 'Робочі простори', - 'social_accounts' => 'Соціальні акаунти', - 'members' => 'Учасники', - 'credits' => 'Кредити', -]; diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php index cb96dab64..be70bf683 100644 --- a/lang/uk/welcome.php +++ b/lang/uk/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => 'Власник вашого акаунта — :name.', 'subscription_required_auto' => 'Ця сторінка оновлюється автоматично — оновлювати вручну не потрібно.', 'progress' => 'Прогрес привітання', - 'go_to_step' => 'Перейти до кроку :step', - 'step_current' => 'Крок :step (поточний)', + 'step_of' => 'Крок :step з :total', + 'back' => 'Назад', + 'steps' => [ + 'persona' => 'Про вас', + 'goals' => 'Ваші цілі', + 'referral_source' => 'Як ви нас знайшли', + 'connect' => 'Соцмережі', + 'plan' => 'Тариф', + ], + 'preview' => [ + 'heading' => 'Ваш workspace набуває форми.', + 'pending' => 'Ще не обрано', + 'networks_empty' => 'Соцмережі ще не підключено', + ], 'personas' => [ 'creator' => 'Автор контенту', 'freelancer' => 'Фрілансер', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => 'Економити час, публікуючи всюди одразу', 'ai_content' => 'Генерувати пости з AI TryPost', - 'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor', + 'use_mcp' => 'Створювати пости через Claude, ChatGPT тощо', 'plan_calendar' => 'Планувати пости в календарі', 'stay_on_brand' => 'Тримати кожен пост у стилі бренду', 'grow_audience' => 'Збільшувати аудиторію та залучення', @@ -38,6 +50,8 @@ 'just_exploring' => 'Поки що просто досліджую', 'other' => 'Щось інше', ], + 'plan_title' => 'Оберіть тариф', + 'plan_description' => 'Обидва плани мають однакові функції. Різниця лише в кількості workspace, і змінити план можна будь-коли.', 'referral_source_title' => 'Як ви нас знайшли?', 'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.', 'referral_source' => [ diff --git a/lang/uk/workspaces.php b/lang/uk/workspaces.php index 50f2487dc..99f1f329b 100644 --- a/lang/uk/workspaces.php +++ b/lang/uk/workspaces.php @@ -9,6 +9,13 @@ 'current' => 'Поточний', 'connections' => ':count підключень', 'posts' => ':count постів', + 'subscription_required' => 'Оформіть підписку, щоб створювати більше workspace.', + 'limit_reached' => 'Ваш план включає один workspace. Оновіть план, щоб додати більше.', + + 'upgrade_dialog' => [ + 'title' => 'Оновіть план, щоб додати workspace', + 'description' => 'Ваш план включає один workspace. Перейдіть на Workspaces для необмеженої кількості workspace — по одному на бренд чи клієнта.', + ], 'create' => [ 'page_title' => 'Створіть свій робочий простір', diff --git a/lang/zh/accounts.php b/lang/zh/accounts.php index bd9a964be..bc47536b1 100644 --- a/lang/zh/accounts.php +++ b/lang/zh/accounts.php @@ -6,6 +6,17 @@ 'description' => '查看你所有已连接的社交账号', 'connect_cta' => '连接', 'connect_another' => '连接另一个', + 'actions' => '账号操作', + 'activate' => '启用', + 'deactivate' => '停用', + 'active' => '已启用', + 'paused' => '已停用', + 'accounts_count' => ':count 个账号', + + 'variants' => [ + 'linkedin-page' => '主页', + 'instagram-facebook' => '通过 Facebook', + ], 'not_connected' => '未连接', 'connect' => '连接', diff --git a/lang/zh/billing.php b/lang/zh/billing.php index c17249c33..9a684e96d 100644 --- a/lang/zh/billing.php +++ b/lang/zh/billing.php @@ -9,57 +9,82 @@ 'cta' => '更新付款方式', ], - 'annual_banner' => [ - 'title' => '免费获得 2 个月', - 'description' => '切换到年付,每月支付更少——套餐不变,其他一切照旧。', - 'cta' => '升级到年付', - ], - 'subscribe' => [ 'billed_monthly' => '按月计费', - 'billed_yearly' => '按年计费', 'prices' => [ - 'workspace' => ['monthly' => '$12', 'yearly_per_month' => '$10', 'yearly' => '$120'], + 'first_month' => '$1', + 'socials' => ['monthly' => '$19', 'yearly_per_month' => '$15.83', 'yearly' => '$190'], + 'workspaces' => ['monthly' => '$99', 'yearly_per_month' => '$82.50', 'yearly' => '$990'], ], ], - 'plan' => [ + 'plans' => [ 'title' => '套餐', - 'description' => '管理你的订阅套餐。', - 'label' => '套餐', - 'workspaces' => '{1}:count 个工作区|[2,*]:count 个工作区', - 'per_workspace' => '每个工作区', - 'price' => '价格', - 'month' => '月', + 'description' => '随时可以升级或降级。', + 'monthly' => '按月', + 'yearly' => '按年', + 'save_two_months' => '免两个月', + 'per_month' => '/月', + 'workspaces_one' => '一个工作区', + 'workspaces_unlimited' => '无限工作区', + 'workspaces_tooltip' => '一个工作区对应一个品牌或客户,与其他工作区相互隔离:拥有自己的社交账号、签名、标签、数据分析、成员权限和 MCP 连接。', + 'current' => '当前套餐', + 'switch_to_yearly' => '切换到年付', + 'switch_to_monthly' => '切换到月付', + 'select' => '选择 :plan', + 'upgrade' => '升级到 :plan', + 'downgrade' => '降级到 :plan', + 'start_first_month' => '以 :price 开始', + 'per_first_month' => '/首月', + 'then_monthly' => '之后 :price/月', + 'billed_yearly_total' => '按年计费 · :price(免两个月)', + 'socials_tagline' => '适合创作者和小品牌。', + 'workspaces_tagline' => '适合代理商和大型业务。', + 'everything_included' => '全部包含', + 'features' => [ + 'networks_all' => '包含所有社交网络', + 'networks_all_tooltip' => '你可以在所有这些平台上发布。', + 'accounts_unlimited' => '社交账号不限', + 'accounts_unlimited_tooltip' => '想连接多少账号都可以,同一平台的多个账号也行。比如三个 Instagram。', + 'calendar' => '日历:月、周、日视图', + 'calendar_tooltip' => '一眼看清整个月:哪些在计划中、已排期、已发布。需要细节时切换到周或日视图。', + 'ai' => 'TryPost Copilot', + 'ai_tooltip' => '帮你撰写和检查帖子的 AI 助手。', + 'mcp' => 'MCP:用 Claude、ChatGPT 或 Grok 发布', + 'mcp_tooltip' => '把 Claude、ChatGPT 或 Grok 连接到你的工作区。让它创建和排期帖子、拉取数据、找出表现最好的内容,并根据你的数据规划下一步。', + 'repurpose' => 'Repurpose:一条内容变成多条', + 'repurpose_tooltip' => '选择一个来源账号。你在那里发布的每条新帖子都会自动转发到你的其他平台。不需要打开 TryPost。', + 'analytics' => '数据分析', + 'analytics_tooltip' => '获取每条帖子、每个账号的曝光、触达、点赞、评论等数据,全部集中在一处。', + 'team' => '成员人数不限', + 'team_tooltip' => '想邀请多少人加入团队都可以,不收额外费用。设定每个人能做什么,发布前先审批。', + ], + ], + + 'plan' => [ 'trial' => '试用', - 'active' => '有效', - 'past_due' => '已逾期', 'cancelling' => '取消中', 'trial_ends' => '试用结束', ], 'subscription' => [ - 'title' => '订阅', - 'description' => '管理你的付款方式、账单信息和订阅。', - 'payment_method' => '付款方式', + 'title' => '付款方式', + 'description' => '在 Stripe 更新你的卡片或账单信息。', 'no_payment_method' => '尚未添加付款方式。', 'expires_on' => '有效期至 :month/:year', - 'manage_label' => '订阅', 'manage_stripe' => '在 Stripe 上管理', ], 'invoices' => [ 'title' => '发票', 'description' => '下载你过往的发票。', - 'empty' => '未找到发票', 'paid' => '已支付', ], 'flash' => [ 'plan_changed' => '你现在使用的是 :plan 套餐。', - 'switched_to_yearly' => '你现在已切换为按年计费。', 'cannot_manage' => '只有账户所有者才能管理账单。', - 'credits_exhausted' => 'AI 额度已用完——你每月 :limit 的额度已用尽。请升级套餐或等到下个月。', + 'too_many_workspaces' => '你有 :count 个工作区。此套餐包含 :limit 个 — 切换前请先删除多余的。', 'subscription_required' => '使用 AI 功能需要有效的订阅。', ], @@ -67,10 +92,5 @@ 'page_title' => '处理中…', 'title' => '正在处理你的订阅', 'description' => '正在为你设置账户,请稍候。这只需片刻。', - 'success_title' => '一切就绪!', - 'success_description' => '你的订阅已生效。正在为你跳转到工作区…', - 'cancelled_title' => '结账已取消', - 'cancelled_description' => '你的结账已取消,未产生任何费用。', - 'retry' => '重试', ], ]; diff --git a/lang/zh/onboarding.php b/lang/zh/onboarding.php deleted file mode 100644 index 228ef1b24..000000000 --- a/lang/zh/onboarding.php +++ /dev/null @@ -1,40 +0,0 @@ - '开始使用', - 'welcome' => '欢迎使用 TryPost,:name', - 'description' => '按下面的步骤了解 TryPost 的用法,并发布你的第一条内容。', - 'skip_step' => '跳过此步骤', - 'continue' => '继续前往 TryPost', - 'status' => [ - 'complete' => '已完成', - 'todo' => '待完成', - 'skipped' => '已跳过', - ], - 'mcp' => [ - 'title' => '连接你的 AI 助手', - 'description' => '将 TryPost 添加为 MCP 服务器,让助手帮你创建和管理社交内容。', - 'copied' => '已复制 MCP URL。', - ], - 'social' => [ - 'title' => '连接社交账号', - 'description' => '至少选择一个网络,让 TryPost 可以发布你的内容。', - 'connected_elsewhere' => '你已在其他工作空间连接了账户,因此这一步已完成。', - ], - 'first_post' => [ - 'title' => '创建第一条内容', - 'description' => '用已连接的助手试试这个入门提示词,或直接在 TryPost 中创建内容。', - 'prompt_label' => '示例提示词', - 'sample_prompt' => '写一条友好的社交内容介绍我的品牌,并为每个已连接的网络做适配。', - 'copy_prompt' => '复制提示词', - 'copied' => '已复制示例提示词。', - 'create_button' => '创建第一条内容', - 'or' => '或', - ], - 'ready' => [ - 'title' => '可以开始发布了', - 'description' => '一切就绪。继续前往 TryPost,开始规划你的内容。', - ], -]; diff --git a/lang/zh/settings.php b/lang/zh/settings.php index e830106c5..3b9b8bbec 100644 --- a/lang/zh/settings.php +++ b/lang/zh/settings.php @@ -311,7 +311,6 @@ 'account' => [ 'tabs' => [ 'account' => '账户', - 'usage' => '用量', 'billing' => '账单', ], 'title' => '账户设置', diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 56d3a1808..42d8fb8b7 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -11,6 +11,7 @@ 'account_settings' => '账户与账单', 'workspace_settings' => '工作区设置', 'log_out' => '退出登录', + 'help' => '帮助', 'workspace' => [ 'connections' => '连接', 'signatures' => '签名', @@ -27,8 +28,6 @@ ], 'analytics' => '分析', 'repurposes' => 'Repurpose', - 'onboarding' => '开始使用', - 'onboarding_hint' => '完成设置', 'posts' => [ 'calendar' => '日历', 'all' => '全部', @@ -42,8 +41,10 @@ 'archive_all' => '全部归档', 'no_notifications' => '暂无通知', 'support' => [ + 'chat' => '在线客服', 'docs' => '文档', 'referral' => '赚取 30% 推荐奖励', 'discord' => 'Discord 社区', + 'community' => '社区', ], ]; diff --git a/lang/zh/usage.php b/lang/zh/usage.php deleted file mode 100644 index dfaa992b3..000000000 --- a/lang/zh/usage.php +++ /dev/null @@ -1,15 +0,0 @@ - '用量', - - 'section_account' => '账户', - 'section_account_description' => '你套餐的配额和上限。', - 'section_ai' => 'AI 额度', - 'section_ai_description' => '使用 AI 功能时会扣除额度。额度在每月 1 日重置。', - - 'workspaces' => '工作区', - 'social_accounts' => '社交账号', - 'members' => '成员', - 'credits' => '额度', -]; diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php index 2d3737224..39ce04666 100644 --- a/lang/zh/welcome.php +++ b/lang/zh/welcome.php @@ -11,8 +11,20 @@ 'subscription_required_owner' => '您的账户所有者是 :name。', 'subscription_required_auto' => '此页面会自动更新 — 无需刷新。', 'progress' => '欢迎进度', - 'go_to_step' => '前往第 :step 步', - 'step_current' => '第 :step 步(当前)', + 'step_of' => '第 :step 步,共 :total 步', + 'back' => '返回', + 'steps' => [ + 'persona' => '关于你', + 'goals' => '你的目标', + 'referral_source' => '如何找到我们', + 'connect' => '社交网络', + 'plan' => '套餐', + ], + 'preview' => [ + 'heading' => '你的工作区正在成形。', + 'pending' => '尚未选择', + 'networks_empty' => '尚未连接任何网络', + ], 'personas' => [ 'creator' => '内容创作者', 'freelancer' => '自由职业者', @@ -29,7 +41,7 @@ 'goals' => [ 'save_time' => '一次发布到所有平台,节省时间', 'ai_content' => '用 TryPost AI 生成帖子', - 'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子', + 'use_mcp' => '通过 Claude、ChatGPT 等创建帖子', 'plan_calendar' => '在日历上规划我的帖子', 'stay_on_brand' => '让每一条帖子都符合品牌调性', 'grow_audience' => '增长我的受众和互动', @@ -38,6 +50,8 @@ 'just_exploring' => '目前只是随便看看', 'other' => '其他需求', ], + 'plan_title' => '选择你的套餐', + 'plan_description' => '两个套餐功能相同,区别只在工作区数量,随时可以切换。', 'referral_source_title' => '您是如何找到我们的?', 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', 'referral_source' => [ diff --git a/lang/zh/workspaces.php b/lang/zh/workspaces.php index c86b7a6d6..316dd27fb 100644 --- a/lang/zh/workspaces.php +++ b/lang/zh/workspaces.php @@ -9,6 +9,13 @@ 'current' => '当前', 'connections' => ':count 个连接', 'posts' => ':count 条帖子', + 'subscription_required' => '订阅后即可创建更多工作区。', + 'limit_reached' => '当前套餐包含一个工作区。升级后可添加更多。', + + 'upgrade_dialog' => [ + 'title' => '升级以添加更多工作区', + 'description' => '当前套餐包含一个工作区。升级到 Workspaces 可获得无限工作区 — 每个品牌或客户一个。', + ], 'create' => [ 'page_title' => '创建你的工作区', diff --git a/phpunit.xml b/phpunit.xml index 97f74bc87..f06f15ddc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -33,6 +33,5 @@ - diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index f447b83e8..1fd235d9c 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -3,16 +3,13 @@ import { Link, usePage } from '@inertiajs/vue3'; import { IconAffiliate, IconAlertTriangle, - IconBrandDiscord, IconCalendar, IconChartBar, IconChevronRight, IconClock, IconFileCheck, IconFileText, - IconGift, IconHash, - IconLifebuoy, IconPencil, IconPhoto, IconPlugConnected, @@ -22,16 +19,14 @@ import { IconWebhook, } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import { create as createPost, index as postsIndex, } from '@/actions/App/Http/Controllers/App/PostController'; import NavMain from '@/components/NavMain.vue'; -import NavSupport from '@/components/NavSupport.vue'; import NotificationBell from '@/components/NotificationBell.vue'; -import SidebarOnboarding from '@/components/onboarding/SidebarOnboarding.vue'; import { Avatar } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -50,6 +45,7 @@ import { useSidebar, } from '@/components/ui/sidebar'; import WorkspaceMenuContent from '@/components/WorkspaceMenuContent.vue'; +import WorkspaceUpgradeDialog from '@/components/workspaces/WorkspaceUpgradeDialog.vue'; import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import { accounts, analytics, calendar } from '@/routes/app'; import { index as assets } from '@/routes/app/assets'; @@ -88,6 +84,8 @@ const { } = useWorkspaceRole(); const { isMobile } = useSidebar(); +const workspaceUpgradeDialogOpen = ref(false); + const mainNavItems = computed(() => [ { title: trans('sidebar.posts.calendar'), @@ -183,24 +181,6 @@ const workspaceNavItems = computed(() => [ icon: IconPlugConnected, }, ]); - -const bottomNavItems = computed(() => [ - { - title: trans('sidebar.support.referral'), - href: 'https://affiliates.trypost.it/', - icon: IconGift, - }, - { - title: trans('sidebar.support.discord'), - href: 'https://trypost.it/discord', - icon: IconBrandDiscord, - }, - { - title: trans('sidebar.support.docs'), - href: 'https://docs.trypost.it', - icon: IconLifebuoy, - }, -]); diff --git a/resources/js/components/HelpMenu.vue b/resources/js/components/HelpMenu.vue new file mode 100644 index 000000000..edfaa5a83 --- /dev/null +++ b/resources/js/components/HelpMenu.vue @@ -0,0 +1,158 @@ + + + diff --git a/resources/js/components/auth/AuthLanguageSwitcher.vue b/resources/js/components/LocaleSwitcher.vue similarity index 67% rename from resources/js/components/auth/AuthLanguageSwitcher.vue rename to resources/js/components/LocaleSwitcher.vue index e949f9bc3..06f422166 100644 --- a/resources/js/components/auth/AuthLanguageSwitcher.vue +++ b/resources/js/components/LocaleSwitcher.vue @@ -1,7 +1,9 @@ diff --git a/resources/js/layouts/auth/AuthSplitLayout.vue b/resources/js/layouts/auth/AuthSplitLayout.vue index cc413ce80..dee353765 100644 --- a/resources/js/layouts/auth/AuthSplitLayout.vue +++ b/resources/js/layouts/auth/AuthSplitLayout.vue @@ -4,7 +4,7 @@ import { IconStarFilled } from '@tabler/icons-vue'; import { trans } from 'laravel-vue-i18n'; import { computed } from 'vue'; -import AuthLanguageSwitcher from '@/components/auth/AuthLanguageSwitcher.vue'; +import LocaleSwitcher from '@/components/LocaleSwitcher.vue'; import type { Auth } from '@/types'; defineProps<{ @@ -58,7 +58,7 @@ const loopedReviews = computed(() => [ class="h-7" /> - +
diff --git a/resources/js/lib/welcomeOptions.ts b/resources/js/lib/welcomeOptions.ts new file mode 100644 index 000000000..a8ef0f52f --- /dev/null +++ b/resources/js/lib/welcomeOptions.ts @@ -0,0 +1,225 @@ +import { + IconArticle, + IconBrandGithub, + IconBrandInstagram, + IconBrandLinkedin, + IconBrandProducthunt, + IconBrandReddit, + IconBrandThreads, + IconBrandTiktokFilled, + IconBrandXFilled, + IconBrandYcombinator, + IconBrandYoutubeFilled, + IconBriefcase, + IconBuildingSkyscraper, + IconBuildingStore, + IconCalendar, + IconClock, + IconCode, + IconCoin, + IconCompass, + IconDots, + IconListSearch, + IconPalette, + IconPlug, + IconRocket, + IconShoppingBag, + IconSparkles, + IconSpeakerphone, + IconTrendingUp, + IconUser, + IconUsers, + IconUsersGroup, +} from '@tabler/icons-vue'; +import type { FunctionalComponent } from 'vue'; + +export interface WelcomeOptionMeta { + icon?: FunctionalComponent; + logo?: string; + iconClass: string; + badge: string; +} + +const FALLBACK: WelcomeOptionMeta = { + icon: IconDots, + iconClass: 'text-foreground', + badge: 'bg-muted', +}; + +export const personaMeta: Record = { + creator: { + icon: IconUser, + iconClass: 'text-rose-700', + badge: 'bg-rose-100', + }, + freelancer: { + icon: IconBriefcase, + iconClass: 'text-amber-700', + badge: 'bg-amber-100', + }, + developer: { + icon: IconCode, + iconClass: 'text-cyan-700', + badge: 'bg-cyan-100', + }, + startup: { + icon: IconRocket, + iconClass: 'text-violet-700', + badge: 'bg-violet-100', + }, + agency: { + icon: IconBuildingSkyscraper, + iconClass: 'text-blue-700', + badge: 'bg-blue-100', + }, + small_business: { + icon: IconBuildingStore, + iconClass: 'text-emerald-700', + badge: 'bg-emerald-100', + }, + marketer: { + icon: IconSpeakerphone, + iconClass: 'text-fuchsia-700', + badge: 'bg-fuchsia-100', + }, + online_store: { + icon: IconShoppingBag, + iconClass: 'text-teal-700', + badge: 'bg-teal-100', + }, + other: FALLBACK, +}; + +export const goalMeta: Record = { + save_time: { + icon: IconClock, + iconClass: 'text-amber-700', + badge: 'bg-amber-100', + }, + ai_content: { + icon: IconSparkles, + iconClass: 'text-violet-700', + badge: 'bg-violet-100', + }, + use_mcp: { + icon: IconPlug, + iconClass: 'text-teal-700', + badge: 'bg-teal-100', + }, + plan_calendar: { + icon: IconCalendar, + iconClass: 'text-blue-700', + badge: 'bg-blue-100', + }, + stay_on_brand: { + icon: IconPalette, + iconClass: 'text-orange-700', + badge: 'bg-orange-100', + }, + grow_audience: { + icon: IconTrendingUp, + iconClass: 'text-rose-700', + badge: 'bg-rose-100', + }, + drive_sales: { + icon: IconCoin, + iconClass: 'text-emerald-700', + badge: 'bg-emerald-100', + }, + manage_clients: { + icon: IconUsersGroup, + iconClass: 'text-cyan-700', + badge: 'bg-cyan-100', + }, + just_exploring: { + icon: IconCompass, + iconClass: 'text-sky-700', + badge: 'bg-sky-100', + }, + other: FALLBACK, +}; + +export const referralSourceMeta: Record = { + google: { + logo: '/images/social/google.svg', + iconClass: '', + badge: 'bg-white', + }, + x: { icon: IconBrandXFilled, iconClass: 'text-white', badge: 'bg-black' }, + linkedin: { + icon: IconBrandLinkedin, + iconClass: 'text-white', + badge: 'bg-[#0A66C2]', + }, + youtube: { + icon: IconBrandYoutubeFilled, + iconClass: 'text-white', + badge: 'bg-[#FF0000]', + }, + tiktok: { + icon: IconBrandTiktokFilled, + iconClass: 'text-white', + badge: 'bg-black', + }, + instagram: { + icon: IconBrandInstagram, + iconClass: 'text-white', + badge: 'bg-gradient-to-br from-[#f9ce34] via-[#ee2a7b] to-[#6228d7]', + }, + threads: { + icon: IconBrandThreads, + iconClass: 'text-white', + badge: 'bg-black', + }, + reddit: { + icon: IconBrandReddit, + iconClass: 'text-white', + badge: 'bg-[#FF4500]', + }, + product_hunt: { + icon: IconBrandProducthunt, + iconClass: 'text-[#FF6154]', + badge: 'bg-white', + }, + github: { + icon: IconBrandGithub, + iconClass: 'text-white', + badge: 'bg-black', + }, + hacker_news: { + icon: IconBrandYcombinator, + iconClass: 'text-white', + badge: 'bg-[#FF6600]', + }, + directories: { + icon: IconListSearch, + iconClass: 'text-sky-800', + badge: 'bg-sky-100', + }, + ai_assistant: { + icon: IconSparkles, + iconClass: 'text-violet-700', + badge: 'bg-violet-100', + }, + friend: { + icon: IconUsers, + iconClass: 'text-emerald-700', + badge: 'bg-emerald-100', + }, + founder: { + icon: IconUser, + iconClass: 'text-orange-800', + badge: 'bg-orange-100', + }, + blog: { + icon: IconArticle, + iconClass: 'text-amber-800', + badge: 'bg-amber-100', + }, + other: FALLBACK, +}; + +export const welcomeOptionMeta = ( + options: Record, + value: string, +): WelcomeOptionMeta => options[value] ?? FALLBACK; diff --git a/resources/js/pages/accounts/Index.vue b/resources/js/pages/accounts/Index.vue index fc0ff70c4..ae992bf2b 100644 --- a/resources/js/pages/accounts/Index.vue +++ b/resources/js/pages/accounts/Index.vue @@ -1,12 +1,13 @@ - - diff --git a/resources/js/pages/settings/account/Account.vue b/resources/js/pages/settings/account/Account.vue index 18af66af7..a6fe9e237 100644 --- a/resources/js/pages/settings/account/Account.vue +++ b/resources/js/pages/settings/account/Account.vue @@ -14,7 +14,6 @@ import { useWorkspaceRole } from '@/composables/useWorkspaceRole'; import AppLayout from '@/layouts/AppLayout.vue'; import { edit as accountEdit, update as accountUpdate } from '@/routes/app/account'; import { index as billingIndex } from '@/routes/app/billing'; -import { index as usageIndex } from '@/routes/app/usage'; interface AccountData { id: string; @@ -31,7 +30,6 @@ const { canManageBilling } = useWorkspaceRole(); const tabs = computed(() => [ { name: 'account', label: trans('settings.account.tabs.account'), href: accountEdit().url }, - { name: 'usage', label: trans('settings.account.tabs.usage'), href: usageIndex().url }, ...(canManageBilling.value ? [{ name: 'billing', label: trans('settings.account.tabs.billing'), href: billingIndex().url }] : []), diff --git a/resources/js/pages/settings/account/Billing.vue b/resources/js/pages/settings/account/Billing.vue index 871d92332..0fe5d2b7e 100644 --- a/resources/js/pages/settings/account/Billing.vue +++ b/resources/js/pages/settings/account/Billing.vue @@ -1,9 +1,10 @@ @@ -85,7 +127,7 @@ const upgradeToAnnual = (): void => { -
+
{
- -
- - - -
-

- {{ $t('billing.annual_banner.title') }} -

-

- {{ $t('billing.annual_banner.description') }} -

-

- {{ monthlyPrice }} - {{ yearlyPerMonthPrice }} - /{{ $t('billing.plan.month') }} · {{ $t('billing.subscribe.billed_yearly') }} -

+
+
+ +
+ + {{ $t('billing.plan.trial') }} + + + {{ $t('billing.plan.cancelling') }} + +

+ {{ $t('billing.plan.trial_ends') }}: + {{ + date.formatDate(trialEndsAt) + }} +

+
- -
- -
- - -
-
-
-

- {{ $t('billing.plan.label') }} -

-
-

- {{ workspacesLabel }} -

- {{ $t('billing.plan.trial') }} - {{ $t('billing.plan.active') }} - {{ $t('billing.plan.past_due') }} - {{ $t('billing.plan.cancelling') }} -
-

- {{ displayPrice(plan?.slug) }} - /{{ $t('billing.plan.month') }} {{ $t('billing.plan.per_workspace') }} -

-

- {{ isYearly ? $t('billing.subscribe.billed_yearly') : $t('billing.subscribe.billed_monthly') }} -

-

- {{ $t('billing.plan.trial_ends') }}: {{ date.formatDate(trialEndsAt) }} -

-
-
- - - -
-
-
-
-
- - +
+ +
-

- {{ $t('billing.subscription.payment_method') }} -

-

- {{ defaultPaymentMethod.brand }} •••• {{ defaultPaymentMethod.last4 }} +

+ {{ defaultPaymentMethod.brand }} •••• + {{ defaultPaymentMethod.last4 }}

- {{ $t('billing.subscription.expires_on', { - month: defaultPaymentMethod.exp_month.toString().padStart(2, '0'), - year: defaultPaymentMethod.exp_year.toString(), - }) }} + {{ + $t('billing.subscription.expires_on', { + month: defaultPaymentMethod.exp_month + .toString() + .padStart(2, '0'), + year: defaultPaymentMethod.exp_year.toString(), + }) + }}

-

- {{ $t('billing.subscription.payment_method') }} -

- {{ $t('billing.subscription.no_payment_method') }} + {{ + $t('billing.subscription.no_payment_method') + }}

-
{ :key="invoice.id" class="flex items-center gap-4 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs" > - - + +
-

{{ date.formatDate(invoice.date) }}

-

{{ invoice.total }}

+

+ {{ date.formatDate(invoice.date) }} +

+

+ {{ invoice.total }} +

- - {{ invoice.status === 'paid' ? $t('billing.invoices.paid') : invoice.status }} + + {{ + invoice.status === 'paid' + ? $t('billing.invoices.paid') + : invoice.status + }} - -
+ diff --git a/resources/js/pages/welcome/Goals.vue b/resources/js/pages/welcome/Goals.vue index 108a2b6b1..518a70477 100644 --- a/resources/js/pages/welcome/Goals.vue +++ b/resources/js/pages/welcome/Goals.vue @@ -1,29 +1,19 @@ + + diff --git a/resources/js/pages/welcome/ReferralSource.vue b/resources/js/pages/welcome/ReferralSource.vue index e3bcdea13..ec11a9363 100644 --- a/resources/js/pages/welcome/ReferralSource.vue +++ b/resources/js/pages/welcome/ReferralSource.vue @@ -1,143 +1,25 @@ @@ -52,9 +67,16 @@ const submit = () => { :show-name="true" /> - + + diff --git a/resources/js/pages/workspaces/Index.vue b/resources/js/pages/workspaces/Index.vue index f87653cc1..82dab94c6 100644 --- a/resources/js/pages/workspaces/Index.vue +++ b/resources/js/pages/workspaces/Index.vue @@ -1,13 +1,16 @@ @@ -45,7 +61,9 @@ const switchToWorkspace = (workspace: Workspace) => { v-for="workspace in workspaces" :key="workspace.id" class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md" - :class="workspace.id === currentWorkspaceId ? 'bg-violet-100' : ''" + :class=" + workspace.id === currentWorkspaceId ? 'bg-violet-100' : '' + " @click="switchToWorkspace(workspace)" > {

{{ workspace.name }}

- {{ trans('workspaces.connections', { count: String(workspace.social_accounts_count) }) }} · {{ trans('workspaces.posts', { count: String(workspace.posts_count) }) }} + {{ + trans('workspaces.connections', { + count: String(workspace.social_accounts_count), + }) + }} + · + {{ + trans('workspaces.posts', { + count: String(workspace.posts_count), + }) + }}

- + {{ $t('workspaces.current') }}
- - - + + + diff --git a/resources/js/types/globals.d.ts b/resources/js/types/globals.d.ts index 202cb9787..92ed5f648 100644 --- a/resources/js/types/globals.d.ts +++ b/resources/js/types/globals.d.ts @@ -3,6 +3,9 @@ import { AppPageProps } from '@/types/index'; declare global { interface Window { dataLayer: Record[]; + $crisp?: { + push: (command: unknown[]) => void; + }; } } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index dbee61008..8f59036aa 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -1,6 +1,16 @@ import { InertiaLinkProps } from '@inertiajs/vue3'; import type { Component } from 'vue'; +import type { AuthPlan, Features, PlanOption } from '@/types/plan'; +import type { WelcomeSummary } from '@/types/welcome'; + +export type { AuthPlan, BillingInterval, Features, PlanOption } from '@/types/plan'; +export type { + WelcomeNetwork, + WelcomeStep, + WelcomeSummary, +} from '@/types/welcome'; + export type WorkspaceRole = 'owner' | 'admin' | 'member' | 'viewer'; export interface Workspace { @@ -11,13 +21,6 @@ export interface Workspace { [key: string]: unknown; } -export interface AuthPlan { - id: string; - slug: string; - name: string; - interval: 'monthly' | 'yearly'; -} - export interface AuthAccount { id: string; name: string; @@ -47,6 +50,10 @@ export interface Usage { export interface FlashData { banner?: string; bannerStyle?: 'success' | 'danger' | 'info' | 'warning'; + success?: string; + error?: string; + warning?: string; + info?: string; plainToken?: string; [key: string]: unknown; } @@ -62,11 +69,6 @@ export interface NavItem { badge?: string; } -export interface OnboardingProgress { - completed: number; - total: number; -} - export interface ContentTypeMediaRule { max_files: number; min_files: number | null; @@ -94,12 +96,14 @@ export interface SharedData { name: string; auth: Auth; flash: FlashData; - onboardingProgress?: OnboardingProgress | false; sidebarOpen: boolean; selfHosted: boolean; - allowMultipleSocialAccounts: boolean; legal: LegalLinks; contentTypeMediaRules?: Record; + features?: Features | null; + usage?: Usage | null; + plans?: PlanOption[]; + welcome?: WelcomeSummary; [key: string]: unknown; } @@ -163,4 +167,3 @@ export interface AiTemplate { supported_formats: string[]; applies_brand_visuals: boolean; } - diff --git a/resources/js/types/plan.ts b/resources/js/types/plan.ts new file mode 100644 index 000000000..ad5f72045 --- /dev/null +++ b/resources/js/types/plan.ts @@ -0,0 +1,75 @@ +export const BILLING_INTERVALS = ['monthly', 'yearly'] as const; + +export type BillingInterval = (typeof BILLING_INTERVALS)[number]; + +export const DEFAULT_BILLING_INTERVAL: BillingInterval = 'monthly'; + +export interface PlanOption { + id: string; + slug: string; + name: string; + workspace_limit: number | null; +} + +export const deniedPlanIdsFor = ( + plans: PlanOption[], + workspaceCount: number, +): string[] => + plans + .filter( + (plan) => + plan.workspace_limit !== null && + workspaceCount > plan.workspace_limit, + ) + .map((plan) => plan.id); + +export const PLAN_CHANGE_ACTIONS = ['upgrade', 'downgrade', 'select'] as const; + +export type PlanChangeAction = (typeof PLAN_CHANGE_ACTIONS)[number]; + +export const PLAN_CHANGE_LABELS = { + upgrade: 'billing.plans.upgrade', + downgrade: 'billing.plans.downgrade', + select: 'billing.plans.select', +} as const satisfies Record< + PlanChangeAction, + `billing.plans.${PlanChangeAction}` +>; + +const workspaceRank = (plan: PlanOption): number => + plan.workspace_limit === null + ? Number.POSITIVE_INFINITY + : plan.workspace_limit; + +export const planChangeAction = ( + current: PlanOption | null | undefined, + target: PlanOption, +): PlanChangeAction => { + if (current === null || current === undefined || current.id === target.id) { + return 'select'; + } + + const from = workspaceRank(current); + const to = workspaceRank(target); + + if (to > from) { + return 'upgrade'; + } + + if (to < from) { + return 'downgrade'; + } + + return 'select'; +}; + +export interface AuthPlan { + id: string; + slug: string; + name: string; + interval: BillingInterval; +} + +export interface Features { + workspaceLimit: number | null; +} diff --git a/resources/js/types/social-account.ts b/resources/js/types/social-account.ts new file mode 100644 index 000000000..584ab8c04 --- /dev/null +++ b/resources/js/types/social-account.ts @@ -0,0 +1,25 @@ +import type { SocialAccountStatusValue } from '@/types/social-account-status'; + +export interface AvailablePlatform { + value: string; + label: string; + network: string; + connect_methods?: string[]; +} + +export interface ConnectedAccount { + id: string; + platform: string; + network: string; + username: string; + display_name: string; + display_label: string; + handle_label: string; + avatar_url: string | null; + profile_url?: string | null; + status: SocialAccountStatusValue | null; + is_active?: boolean; +} + +export const isConnectionLost = (account: ConnectedAccount): boolean => + account.status === 'disconnected' || account.status === 'token_expired'; diff --git a/resources/js/types/welcome.ts b/resources/js/types/welcome.ts new file mode 100644 index 000000000..aa4df4367 --- /dev/null +++ b/resources/js/types/welcome.ts @@ -0,0 +1,20 @@ +export type WelcomeStep = + | 'persona' + | 'goals' + | 'referral_source' + | 'connect' + | 'plan'; + +export interface WelcomeNetwork { + id: string; + platform: string; + display_label: string; + username: string | null; + avatar_url: string | null; +} + +export interface WelcomeSummary { + persona: string | null; + goals: string[]; + networks: WelcomeNetwork[]; +} diff --git a/routes/app.php b/routes/app.php index eb8c92b13..f4591254d 100644 --- a/routes/app.php +++ b/routes/app.php @@ -11,7 +11,6 @@ use App\Http\Controllers\App\LinkPreviewController; use App\Http\Controllers\App\McpSettingsController; use App\Http\Controllers\App\NotificationController; -use App\Http\Controllers\App\OnboardingController; use App\Http\Controllers\App\PostAiCreateController; use App\Http\Controllers\App\PostAiGenerateController; use App\Http\Controllers\App\PostAiRegenerateMediaController; @@ -25,7 +24,6 @@ use App\Http\Controllers\App\Settings\NotificationPreferenceController; use App\Http\Controllers\App\Settings\ProfileController; use App\Http\Controllers\App\Settings\SettingsController; -use App\Http\Controllers\App\Settings\UsageController; use App\Http\Controllers\App\UnsplashController; use App\Http\Controllers\App\WebhookController; use App\Http\Controllers\App\WelcomeController; @@ -72,6 +70,10 @@ Route::post('welcome/connect', [WelcomeController::class, 'storeConnect']) ->middleware('throttle:6,1') ->name('app.welcome.connect.store'); + Route::get('welcome/plan', [WelcomeController::class, 'plan'])->name('app.welcome.plan'); + Route::post('welcome/plan', [WelcomeController::class, 'storePlan']) + ->middleware('throttle:6,1') + ->name('app.welcome.plan.store'); Route::get('welcome/subscription-required', [WelcomeController::class, 'subscriptionRequired'])->name('app.welcome.subscription-required'); Route::get('billing/processing', [BillingController::class, 'processing'])->name('app.billing.processing'); @@ -152,10 +154,6 @@ // Routes that require account access and a current workspace Route::middleware(['auth', EnsureAccountReady::class, EnsureHasWorkspace::class])->group(function () { - Route::get('onboarding', [OnboardingController::class, 'index'])->name('app.onboarding'); - Route::post('onboarding/mcp/skip', [OnboardingController::class, 'skipMcp'])->name('app.onboarding.mcp.skip'); - Route::post('onboarding/complete', [OnboardingController::class, 'complete'])->name('app.onboarding.complete'); - // Discord — live lookups for the composer (channel picker + mention autocomplete). // Throttled because they proxy the shared bot's (rate-limited) Discord API. Route::get('discord/accounts/{account}/channels', [AppDiscordController::class, 'channels']) @@ -282,12 +280,11 @@ // Account Settings Route::get('settings/account', [AccountController::class, 'edit'])->name('app.account.edit'); Route::put('settings/account', [AccountController::class, 'update'])->name('app.account.update'); - Route::get('settings/account/usage', [UsageController::class, 'index'])->name('app.usage.index'); // Billing Route::get('settings/account/billing', [BillingController::class, 'index'])->name('app.billing.index'); Route::get('settings/account/billing/portal', [BillingController::class, 'portal'])->name('app.billing.portal'); - Route::post('settings/account/billing/swap-to-yearly', [BillingController::class, 'swapToYearly'])->name('app.billing.swap-to-yearly'); + Route::post('settings/account/billing/change-plan', [BillingController::class, 'changePlan'])->name('app.billing.change-plan'); }); diff --git a/tests/Browser/AccountsPageTest.php b/tests/Browser/AccountsPageTest.php new file mode 100644 index 000000000..7b74d2390 --- /dev/null +++ b/tests/Browser/AccountsPageTest.php @@ -0,0 +1,129 @@ +script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((r) => setTimeout(r, 50)); + } + })(); + JS); +} + +function accountsOwner(): User +{ + $user = User::factory()->create(); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + return $user->fresh(); +} + +function accountsOwnerWithLinkedIn(): User +{ + $user = accountsOwner(); + + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $user->current_workspace_id, + 'platform_user_id' => 'li-connected', + ]); + + return $user; +} + +test('a workspace without accounts lists every network with a connect slot', function () { + $this->actingAs(accountsOwner()); + + $page = visit(route('app.accounts')); + + waitForAccountsTestId($page, 'network-group-linkedin'); + + $page->assertVisible('@network-group-linkedin') + ->assertVisible('@connect-linkedin') + ->assertVisible('@connect-x') + ->assertMissing('@connect-another-linkedin') + ->assertMissing('@connect-account-button') + ->assertNoJavaScriptErrors(); +}); + +test('every network is listed, connected ones grouped with a slot for one more', function () { + $user = accountsOwnerWithLinkedIn(); + + SocialAccount::factory()->linkedinPage()->create([ + 'workspace_id' => $user->current_workspace_id, + 'platform_user_id' => 'li-page', + ]); + + $this->actingAs($user); + + $page = visit(route('app.accounts')); + + waitForAccountsTestId($page, 'network-group-linkedin'); + + $page->assertVisible('@network-group-linkedin') + ->assertVisible('@connect-another-linkedin') + ->assertMissing('@connect-linkedin') + ->assertVisible('@network-group-x') + ->assertVisible('@connect-x') + ->assertMissing('@connect-another-x') + ->assertNoJavaScriptErrors(); +}); + +test('the accounts page has no header connect catalog', function () { + $this->actingAs(accountsOwnerWithLinkedIn()); + + $page = visit(route('app.accounts')); + + waitForAccountsTestId($page, 'network-group-linkedin'); + + $page->assertMissing('@connect-account-button') + ->assertMissing('@connect-account-dialog') + ->assertVisible('@connect-another-linkedin') + ->assertNoJavaScriptErrors(); +}); + +test('a lost connection offers reconnect on the card and disconnect in its menu', function () { + $user = accountsOwnerWithLinkedIn(); + + $account = SocialAccount::factory()->x()->tokenExpired()->create([ + 'workspace_id' => $user->current_workspace_id, + 'platform_user_id' => 'x-expired', + ]); + + $this->actingAs($user); + + $page = visit(route('app.accounts')); + + waitForAccountsTestId($page, "reconnect-button-{$account->id}"); + + $page->assertVisible("@reconnect-button-{$account->id}") + ->click("@account-menu-{$account->id}"); + + waitForAccountsTestId($page, "disconnect-{$account->id}"); + + $page->assertVisible("@reconnect-{$account->id}") + ->assertVisible("@disconnect-{$account->id}") + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/NetworkConnectGridTest.php b/tests/Browser/NetworkConnectGridTest.php deleted file mode 100644 index 69778880e..000000000 --- a/tests/Browser/NetworkConnectGridTest.php +++ /dev/null @@ -1,92 +0,0 @@ -script(<< { - const sel = '[data-testid="{$testId}"]'; - for (let i = 0; i < 100; i++) { - const el = document.querySelector(sel); - if (el && el.getBoundingClientRect().height > 0) return; - await new Promise((r) => setTimeout(r, 50)); - } - })(); - JS); -} - -function gridOwnerWithLinkedIn(): User -{ - $user = User::factory()->create(); - - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); - $user->update(['current_workspace_id' => $workspace->id]); - - SocialAccount::factory()->linkedin()->create([ - 'workspace_id' => $workspace->id, - 'platform_user_id' => 'li-connected', - ]); - - return $user->fresh(); -} - -test('a taken network offers no second card when multiples are disabled', function () { - config(['trypost.allow_multiple_social_accounts' => false]); - - $this->actingAs(gridOwnerWithLinkedIn()); - - $page = visit(route('app.accounts')); - - waitForGridTestId($page, 'connect-x'); - - $page->assertVisible('@connect-x') - ->assertMissing('@connect-linkedin') - ->assertNoJavaScriptErrors(); -}); - -test('a taken network offers another card when multiples are allowed', function () { - config(['trypost.allow_multiple_social_accounts' => true]); - - $this->actingAs(gridOwnerWithLinkedIn()); - - $page = visit(route('app.accounts')); - - waitForGridTestId($page, 'connect-linkedin'); - - $page->assertVisible('@connect-linkedin') - ->assertVisible('@connect-x') - ->assertNoJavaScriptErrors(); -}); - -test('a lost connection offers both reconnect and disconnect', function () { - $user = gridOwnerWithLinkedIn(); - - $account = SocialAccount::factory()->x()->tokenExpired()->create([ - 'workspace_id' => $user->current_workspace_id, - 'platform_user_id' => 'x-expired', - ]); - - $this->actingAs($user); - - $page = visit(route('app.accounts')); - - waitForGridTestId($page, "disconnect-{$account->id}"); - - $page->assertVisible("@reconnect-{$account->id}") - ->assertVisible("@disconnect-{$account->id}") - ->assertNoJavaScriptErrors(); -}); diff --git a/tests/Browser/RepurposeTest.php b/tests/Browser/RepurposeTest.php index f453da5ea..c4a0c4521 100644 --- a/tests/Browser/RepurposeTest.php +++ b/tests/Browser/RepurposeTest.php @@ -107,8 +107,6 @@ function repurposeOwnerWithAccounts(): array test('the source account is picked from a searchable list on the edit page', function () { [$user, $workspace, $source] = repurposeOwnerWithAccounts(); - config()->set('trypost.allow_multiple_social_accounts', true); - $other = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]); $repurpose = Repurpose::factory()->create([ @@ -130,8 +128,6 @@ function repurposeOwnerWithAccounts(): array test('switching the source hands the old one back to the destinations before saving', function () { [$user, $workspace, $source] = repurposeOwnerWithAccounts(); - config()->set('trypost.allow_multiple_social_accounts', true); - $facebook = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Facebook]); $repurpose = Repurpose::factory()->create([ @@ -281,8 +277,6 @@ function repurposeOwnerWithAccounts(): array test('a source account that needs reconnecting says so in the picker', function () { [$user, $workspace, $source] = repurposeOwnerWithAccounts(); - config()->set('trypost.allow_multiple_social_accounts', true); - $broken = SocialAccount::factory()->for($workspace)->create([ 'platform' => Platform::Instagram, 'status' => AccountStatus::TokenExpired, diff --git a/tests/Browser/WelcomeConnectTest.php b/tests/Browser/WelcomeConnectTest.php index 5f839be5b..6de1e1f1c 100644 --- a/tests/Browser/WelcomeConnectTest.php +++ b/tests/Browser/WelcomeConnectTest.php @@ -56,13 +56,13 @@ function welcomeOwnerOnConnectStep(): User $page = visit(route('app.welcome.connect')); - waitForWelcomeTestId($page, 'welcome-start-checkout'); + waitForWelcomeTestId($page, 'welcome-connect-continue'); $page->assertRoute('app.welcome.connect') ->assertVisible('@welcome-connect-grid') - ->assertVisible('@welcome-start-checkout') - ->assertDisabled('@welcome-start-checkout') - ->assertVisible('@welcome-step-4') + ->assertVisible('@welcome-connect-continue') + ->assertDisabled('@welcome-connect-continue') + ->assertVisible('@welcome-step-connect') ->assertNoJavaScriptErrors(); }); @@ -78,11 +78,11 @@ function welcomeOwnerOnConnectStep(): User $page = visit(route('app.welcome.connect')); - waitForWelcomeTestId($page, 'welcome-start-checkout'); + waitForWelcomeTestId($page, 'welcome-connect-continue'); $page->assertRoute('app.welcome.connect') ->assertVisible('@welcome-connect-grid') - ->assertEnabled('@welcome-start-checkout') + ->assertEnabled('@welcome-connect-continue') ->assertNoJavaScriptErrors(); }); @@ -95,9 +95,9 @@ function welcomeOwnerOnConnectStep(): User $page = visit(route('app.welcome.connect')); - waitForWelcomeTestId($page, 'welcome-step-3'); + waitForWelcomeTestId($page, 'welcome-back'); - $page->click('@welcome-step-3'); + $page->click('@welcome-back'); waitForWelcomeTestId($page, 'welcome-referral-continue'); @@ -131,13 +131,13 @@ function welcomeOwnerOnConnectStep(): User $page = visit(route('app.welcome.connect')); - waitForWelcomeTestId($page, 'welcome-start-checkout'); + waitForWelcomeTestId($page, 'welcome-connect-continue'); - $page->assertEnabled('@welcome-start-checkout'); + $page->assertEnabled('@welcome-connect-continue'); $account->delete(); - $page->click('@welcome-start-checkout'); + $page->click('@welcome-connect-continue'); waitForWelcomeTestId($page, 'welcome-connect-error'); diff --git a/tests/Browser/WelcomePlanTest.php b/tests/Browser/WelcomePlanTest.php new file mode 100644 index 000000000..bc4c08caa --- /dev/null +++ b/tests/Browser/WelcomePlanTest.php @@ -0,0 +1,94 @@ +script(<< { + const sel = '[data-testid="{$testId}"]'; + for (let i = 0; i < 100; i++) { + const el = document.querySelector(sel); + if (el && el.getBoundingClientRect().height > 0) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + })(); + JS); +} + +function welcomeOwnerOnPlanStep(): User +{ + $user = User::factory()->create(); + $user->update([ + 'persona' => Persona::Agency->value, + 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::ProductHunt->value, + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $workspace->id, + ]); + + return $user->fresh(); +} + +test('the plan step shows both plans with networks and no yearly toggle', function () { + config(['trypost.self_hosted' => false]); + + $this->actingAs(welcomeOwnerOnPlanStep()); + + $page = visit(route('app.welcome.plan')); + + waitForWelcomePlanTestId($page, 'plan-networks-info-socials'); + waitForWelcomePlanTestId($page, 'plan-card-socials'); + waitForWelcomePlanTestId($page, 'plan-card-workspaces'); + waitForWelcomePlanTestId($page, 'plan-price-first-month-socials'); + waitForWelcomePlanTestId($page, 'language-picker'); + + $page->assertRoute('app.welcome.plan') + ->assertVisible('@plan-card-socials') + ->assertVisible('@plan-card-workspaces') + ->assertMissing('@plan-price-regular-socials') + ->assertMissing('@plan-price-regular-workspaces') + ->assertVisible('@plan-price-first-month-socials') + ->assertVisible('@plan-price-first-month-workspaces') + ->assertSeeIn('@plan-price-first-month-socials', '$1') + ->assertSeeIn('@plan-price-first-month-workspaces', '$1') + ->assertSeeIn('@plan-price-suffix-socials', '/first month') + ->assertSeeIn('@plan-price-suffix-workspaces', '/first month') + ->assertSeeIn('@plan-price-note-socials', 'Then $19/month') + ->assertSeeIn('@plan-price-note-workspaces', 'Then $99/month') + ->assertSeeIn('@plan-highlight-socials', 'One workspace') + ->assertSeeIn('@plan-highlight-workspaces', 'Unlimited workspaces') + ->assertVisible('@plan-workspaces-info-socials') + ->assertVisible('@plan-workspaces-info-workspaces') + ->assertVisible('@plan-networks-info-socials') + ->assertVisible('@plan-networks-info-workspaces') + ->assertVisible('@plan-select-socials') + ->assertVisible('@plan-select-workspaces') + ->assertVisible('@plan-highlight-socials') + ->assertVisible('@plan-highlight-workspaces') + ->assertVisible('@language-picker') + ->assertMissing('@plan-interval-yearly') + ->assertMissing('@plan-interval-monthly') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php b/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php deleted file mode 100644 index bef35ec0d..000000000 --- a/tests/Feature/Actions/Onboarding/ResolveOnboardingStatusTest.php +++ /dev/null @@ -1,674 +0,0 @@ - false]); - - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); - $this->user->refresh(); - - subscribeAccount($this->user->account); -}); - -test('resolves the empty onboarding state', function () { - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status)->toBe([ - 'mcp_connected' => false, - 'social_connected' => false, - 'first_post_created' => false, - 'skipped_steps' => [], - 'all_complete' => false, - 'show_progress' => true, - 'completed_at' => null, - 'dismissed_at' => null, - ]); -}); - -test('resolves an OAuth token as MCP connected', function () { - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status)->toMatchArray([ - 'mcp_connected' => true, - 'social_connected' => false, - 'first_post_created' => false, - 'all_complete' => false, - ]); -}); - -test('does not resolve a personal access token as MCP connected', function () { - $this->user->createToken('Personal Access Token'); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('does not resolve a workspace-bound personal access token as MCP connected', function () { - $result = $this->user->createToken('Personal Access Token'); - AccessToken::find($result->token->id) - ->forceFill(['workspace_id' => $this->workspace->id]) - ->saveQuietly(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('does not resolve a revoked token as MCP connected', function () { - $token = mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - $token->forceFill(['revoked' => true])->saveQuietly(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('resolves a social account in the current workspace as connected', function () { - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status)->toMatchArray([ - 'mcp_connected' => false, - 'social_connected' => true, - 'first_post_created' => false, - 'all_complete' => false, - ]); -}); - -test('captures completed steps when progress syncs', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Bus::fake(); - - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); - - app(ResolveOnboardingStatus::class)->syncProgress($this->user); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' - && data_get($event->payload, 'event') === OnboardingEvent::StepCompleted->value - && data_get($event->payload, 'properties.step') === 'social'); -}); - -test('does not resolve an expired oauth token as mcp connected', function () { - $token = AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('bound mcp grants count without a current workspace switcher value', function () { - $this->user->update(['current_workspace_id' => null]); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh()); - - expect($status)->toMatchArray([ - 'mcp_connected' => true, - 'social_connected' => true, - 'first_post_created' => true, - 'all_complete' => true, - ]); -}); - -test('does not resolve an unbound oauth token as mcp connected', function () { - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient())); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse() - ->and($status['all_complete'])->toBeFalse(); -}); - -test('members do not see the progress checklist', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($member->fresh()))->toBeFalse() - ->and(app(ResolveOnboardingStatus::class)->handle($member->fresh())['show_progress'])->toBeFalse(); -}); - -test('generic trial without card still shows progress for the owner', function () { - config(['trypost.billing.require_card_for_trial' => false]); - $this->user->account->subscriptions()->delete(); - $this->user->account->update(['trial_ends_at' => now()->addDays(7)]); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBe([ - 'completed' => 0, - 'total' => ResolveOnboardingStatus::totalSteps(), - ]); -}); - -test('resolves a social account in another workspace as connected', function () { - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['social_connected'])->toBeTrue(); -}); - -test('does not resolve an unusable social account as connected', function (Status $status) { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'status' => $status, - ]); - - $onboarding = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($onboarding['social_connected'])->toBeFalse(); -})->with([ - 'disconnected' => Status::Disconnected, - 'token expired' => Status::TokenExpired, -]); - -test('resolves any post in the current workspace as the first post', function () { - Post::factory()->failed()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ]); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status)->toMatchArray([ - 'mcp_connected' => false, - 'social_connected' => false, - 'first_post_created' => true, - 'all_complete' => false, - ]); -}); - -test('resolves a post in another workspace as the first post', function () { - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - Post::factory()->create([ - 'workspace_id' => $otherWorkspace->id, - 'user_id' => $this->user->id, - ]); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['first_post_created'])->toBeTrue(); -}); - -test('marks onboarding completed once all three steps are complete', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); - Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ]); - - $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user); - - expect($status)->toBe([ - 'mcp_connected' => true, - 'social_connected' => true, - 'first_post_created' => true, - 'skipped_steps' => [], - 'all_complete' => true, - 'show_progress' => false, - 'completed_at' => now()->toIso8601String(), - 'dismissed_at' => null, - ])->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Carbon::setTestNow(now()->addHour()); - app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh()); - - expect($this->user->account->fresh()->onboarding_completed_at?->toIso8601String()) - ->toBe('2026-07-24T12:00:00+00:00'); -}); - -test('handle does not mutate the account when every step is complete', function () { - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status)->toMatchArray([ - 'all_complete' => true, - 'show_progress' => false, - 'completed_at' => null, - ])->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('stamps completion when the last step completes off the onboarding page', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Carbon::setTestNow('2026-07-24 12:00:00'); - Bus::fake(); - - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); - - expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); - - Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ]); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value); -}); - -test('does not resolve a teammate oauth token as mcp connected', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - $member->update(['current_workspace_id' => $this->workspace->id]); - AccessToken::withoutEvents(fn () => mcpAccessToken($member, mcpOauthClient(), $this->workspace)); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('does not resolve an unscoped oauth token as mcp connected', function () { - AccessToken::withoutEvents( - fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace, scopes: []), - ); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('does not resolve a viewer oauth token as mcp connected', function () { - $viewer = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); - $viewer->update(['current_workspace_id' => $this->workspace->id]); - AccessToken::withoutEvents(fn () => mcpAccessToken($viewer, mcpOauthClient(), $this->workspace)); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['mcp_connected'])->toBeFalse(); -}); - -test('dismissed onboarding does not show the progress checklist', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh()); - - expect($status['show_progress'])->toBeFalse() - ->and($status['dismissed_at'])->toBe(now()->toIso8601String()); -}); - -test('completed onboarding returns immediately without resolving steps or capturing analytics', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Carbon::setTestNow('2026-07-24 12:00:00'); - Bus::fake(); - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh()); - - expect($status)->toBe([ - 'mcp_connected' => true, - 'social_connected' => true, - 'first_post_created' => true, - 'skipped_steps' => [], - 'all_complete' => true, - 'show_progress' => false, - 'completed_at' => now()->toIso8601String(), - 'dismissed_at' => null, - ]); - Bus::assertNothingDispatched(); -}); - -test('self-hosted onboarding shows the progress checklist for owners', function () { - config(['trypost.self_hosted' => true]); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['show_progress'])->toBeTrue(); -}); - -test('unsubscribed account does not show the progress checklist', function () { - $this->user->account->subscriptions()->delete(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user); - - expect($status['show_progress'])->toBeFalse(); -}); - -test('sidebar progress returns progress counts while onboarding is active', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ]); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user))->toBe([ - 'completed' => 1, - 'total' => ResolveOnboardingStatus::totalSteps(), - ]); -}); - -test('checklist and sidebar progress both count social and posts from any workspace', function () { - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $otherWorkspace->id, - ])); - - $emptyWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $emptyWorkspace->id]); - - expect(app(ResolveOnboardingStatus::class)->handle($this->user->fresh()))->toMatchArray([ - 'mcp_connected' => true, - 'social_connected' => true, - 'first_post_created' => false, - 'all_complete' => false, - 'show_progress' => true, - ])->and(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBe([ - 'completed' => 2, - 'total' => ResolveOnboardingStatus::totalSteps(), - ]); -}); - -test('sidebar progress returns false when the banner should not show', function () { - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBeFalse(); -}); - -test('syncProgress stamps via the owner when a teammate unlocks the last step', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - expect(app(ResolveOnboardingStatus::class)->syncProgress($member->fresh())['all_complete'])->toBeFalse(); - - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $member->id, - ])); - - expect(app(ResolveOnboardingStatus::class)->syncProgress($member->fresh())['all_complete'])->toBeTrue() - ->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('markCompleted refuses teammates', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - - expect(app(ResolveOnboardingStatus::class)->markCompleted($member->fresh()))->toBeFalse() - ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('teammate mcp connection does not complete the mcp onboarding step', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Bus::fake(); - Cache::flush(); - - $member = User::factory()->create([ - 'account_id' => $this->user->account_id, - 'current_workspace_id' => $this->workspace->id, - ]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - - mcpAccessToken($member, mcpOauthClient(), $this->workspace); - - expect(app(ResolveOnboardingStatus::class)->handle($this->user->fresh())['mcp_connected'])->toBeFalse(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => $event->method === 'capture' - && data_get($event->payload, 'event') === OnboardingEvent::StepCompleted->value - && data_get($event->payload, 'properties.step') === 'mcp', - ); -}); - -test('markCompleted leaves the in-memory account clean', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user))->toBeTrue() - ->and($this->user->account->isDirty())->toBeFalse() - ->and($this->user->account->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id, - ); -}); - -test('markCompleted refuses to stamp after onboarding was dismissed', function () { - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - expect(app(ResolveOnboardingStatus::class)->markCompleted($this->user->fresh()))->toBeFalse() - ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('syncProgress stamps when another workspace already finished social and post', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - - $emptyWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $emptyWorkspace->id]); - - $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh()); - - expect($status['all_complete'])->toBeTrue() - ->and($status['show_progress'])->toBeFalse() - ->and($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('sidebar progress hides without writing when another workspace already finished activation', function () { - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - - $emptyWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $emptyWorkspace->id]); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBeFalse() - ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('sidebar progress does not query step state for dismissed accounts', function () { - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - $queries = []; - DB::listen(fn ($query) => $queries[] = $query->sql); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBeFalse(); - - expect(collect($queries)->filter(fn (string $sql): bool => str_contains($sql, 'oauth_access_tokens') - || str_contains($sql, 'social_accounts') - || str_contains($sql, 'posts')))->toBeEmpty(); -}); - -test('sidebar progress does not query step state for members', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - $queries = []; - DB::listen(fn ($query) => $queries[] = $query->sql); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($member->fresh()))->toBeFalse(); - - expect(collect($queries)->filter(fn (string $sql): bool => str_contains($sql, 'oauth_access_tokens') - || str_contains($sql, 'social_accounts') - || str_contains($sql, 'posts')))->toBeEmpty(); -}); - -test('sidebar progress works in self hosted mode', function () { - config(['trypost.self_hosted' => true]); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ]); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBe([ - 'completed' => 1, - 'total' => ResolveOnboardingStatus::totalSteps(), - ]); -}); - -test('skipMcp marks the optional MCP step as skipped without completing the checklist', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - expect(app(ResolveOnboardingStatus::class)->skipMcp($this->user))->toBeTrue(); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh()); - - expect($status['skipped_steps'])->toBe(['mcp']) - ->and($status['mcp_connected'])->toBeFalse() - ->and($status['all_complete'])->toBeFalse() - ->and($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('skipMcp completes the checklist when MCP was the last open step', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - expect(app(ResolveOnboardingStatus::class)->skipMcp($this->user))->toBeTrue(); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - // Ready UI must keep the Skipped badge — do not force mcp_connected=true. - $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh()); - - expect($status['mcp_connected'])->toBeFalse() - ->and($status['skipped_steps'])->toBe(['mcp']) - ->and($status['all_complete'])->toBeTrue(); -}); - -test('skipMcp refuses when MCP is already connected or already skipped', function () { - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - - expect(app(ResolveOnboardingStatus::class)->skipMcp($this->user->fresh()))->toBeFalse(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBeNull(); -}); - -test('sidebar progress counts a skipped step as done', function () { - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - - app(ResolveOnboardingStatus::class)->skipMcp($this->user); - - expect(app(ResolveOnboardingStatus::class)->sidebarProgress($this->user->fresh()))->toBe([ - 'completed' => 2, - 'total' => ResolveOnboardingStatus::totalSteps(), - ]); -}); - -test('connecting the mcp step after skipping it clears the skip on sync', function () { - app(ResolveOnboardingStatus::class)->skipMcp($this->user); - - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - - $status = app(ResolveOnboardingStatus::class)->syncProgress($this->user->fresh()); - - expect($status['mcp_connected'])->toBeTrue() - ->and($status['skipped_steps'])->toBe([]) - ->and($this->user->account->fresh()->onboarding_skipped_steps)->toBeNull(); -}); - -test('connecting mcp after a skipped step completed onboarding replaces the skipped status', function () { - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - expect(app(ResolveOnboardingStatus::class)->skipMcp($this->user))->toBeTrue() - ->and($this->user->account->fresh()->onboarding_completed_at)->not->toBeNull(); - - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - - $status = app(ResolveOnboardingStatus::class)->handle($this->user->fresh()); - - expect($status['mcp_connected'])->toBeTrue() - ->and($status['skipped_steps'])->toBe([]); -}); diff --git a/tests/Feature/Api/PostApiTest.php b/tests/Feature/Api/PostApiTest.php index 4b033de95..a52ee1069 100644 --- a/tests/Feature/Api/PostApiTest.php +++ b/tests/Feature/Api/PostApiTest.php @@ -125,7 +125,6 @@ }); it('rejects creating a post with an inactive social account', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $inactive = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn, diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index 027469979..8f22c68d6 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -35,18 +35,6 @@ expect($page['props']['selfHosted'])->toBeTrue(); }); -test('login page exposes allowMultipleSocialAccounts independently of selfHosted', function () { - config()->set('trypost.self_hosted', false); - config()->set('trypost.allow_multiple_social_accounts', true); - - $response = $this->get(route('login')); - - $response->assertOk(); - $page = $response->original->getData()['page']; - expect($page['props']['selfHosted'])->toBeFalse() - ->and($page['props']['allowMultipleSocialAccounts'])->toBeTrue(); -}); - test('users can authenticate using the login screen', function () { $user = User::factory()->create(); diff --git a/tests/Feature/Auth/SignupRequiresCheckoutTest.php b/tests/Feature/Auth/SignupRequiresCheckoutTest.php index c89bbc734..28b88d3c3 100644 --- a/tests/Feature/Auth/SignupRequiresCheckoutTest.php +++ b/tests/Feature/Auth/SignupRequiresCheckoutTest.php @@ -35,6 +35,6 @@ 'registration_ip' => '127.0.0.1', ]); - expect($user->account->plan->slug)->toBe(Slug::Workspace); + expect($user->account->plan->slug)->toBe(Slug::Socials); expect($user->account->trial_ends_at)->not->toBeNull(); }); diff --git a/tests/Feature/Billing/BillingCycleTest.php b/tests/Feature/Billing/BillingCycleTest.php index c4ed42327..d303af977 100644 --- a/tests/Feature/Billing/BillingCycleTest.php +++ b/tests/Feature/Billing/BillingCycleTest.php @@ -2,36 +2,10 @@ declare(strict_types=1); -use App\Models\Account; use App\Models\AiUsageLog; -use App\Models\Plan; -use App\Models\Workspace; use App\Support\BillingCycle; use Illuminate\Support\Carbon; -test('monthly allotment is credits per workspace times workspace count', function () { - $account = billingAccount('price_month', workspaces: 3); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(7500); -}); - -test('yearly allotment grants twelve months upfront per workspace', function () { - $account = billingAccount('price_year', workspaces: 2); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(60000); -}); - -test('during trial the allotment is the monthly amount even on a yearly price', function () { - Carbon::setTestNow('2026-06-20 12:00:00'); - - $account = billingAccount('price_year', [ - 'created_at' => Carbon::parse('2026-06-18'), - 'trial_ends_at' => Carbon::parse('2026-06-25'), - ], workspaces: 1); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(2500); -}); - test('during trial the window spans the subscription creation to the trial end', function () { Carbon::setTestNow('2026-06-20 12:00:00'); @@ -87,14 +61,6 @@ expect(BillingCycle::for($account)->usedCredits())->toBe(10); }); -test('without a subscription the allotment falls back to a monthly amount', function () { - $plan = Plan::query()->firstOrFail(); - $account = Account::factory()->create(['plan_id' => $plan->id, 'trial_ends_at' => null]); - Workspace::factory()->count(2)->create(['account_id' => $account->id]); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(5000); -}); - test('monthly window clamps an end-of-month anchor without drift', function () { Carbon::setTestNow('2026-06-20 12:00:00'); $account = billingAccount('price_month', ['created_at' => Carbon::parse('2026-01-31')]); @@ -114,10 +80,3 @@ expect($cycle->periodStart()->toDateString())->toBe('2026-02-28') ->and($cycle->periodEnd()->toDateString())->toBe('2027-02-28'); }); - -test('allotment is zero when the account has no plan', function () { - $account = Account::factory()->create(['plan_id' => null, 'trial_ends_at' => null]); - Workspace::factory()->count(2)->create(['account_id' => $account->id]); - - expect(BillingCycle::for($account)->creditAllotment())->toBe(0); -}); diff --git a/tests/Feature/Billing/ChangePlanTest.php b/tests/Feature/Billing/ChangePlanTest.php new file mode 100644 index 000000000..b83590ba2 --- /dev/null +++ b/tests/Feature/Billing/ChangePlanTest.php @@ -0,0 +1,494 @@ + false]); + + $this->socials = Plan::where('slug', Slug::Socials)->firstOrFail(); + $this->socials->update([ + 'stripe_monthly_price_id' => 'price_socials_monthly', + 'stripe_yearly_price_id' => 'price_socials_yearly', + ]); + + $this->workspaces = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $this->workspaces->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); +}); + +afterEach(function () { + RecordingSwapSubscription::$swappedPrices = []; + Cashier::useSubscriptionModel(Subscription::class); +}); + +$withWorkspace = function (User $user): Workspace { + $workspace = Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + $workspace->members()->attach($user->id, ['role' => Role::Admin->value]); + $user->update(['current_workspace_id' => $workspace->id]); + + return $workspace; +}; + +test('a non-owner cannot change the plan', function () use ($withWorkspace) { + $owner = User::factory()->create(); + $workspace = $withWorkspace($owner); + subscribeAccount($owner->account); + + $member = User::factory()->create(['account_id' => $owner->account_id]); + $workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $workspace->id]); + + $this->actingAs($member->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertForbidden(); +}); + +test('an archived plan is rejected', function () use ($withWorkspace) { + $user = User::factory()->create(); + $withWorkspace($user); + subscribeAccount($user->account); + $legacy = Plan::where('slug', Slug::Workspace)->firstOrFail(); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $legacy->id, + 'interval' => 'monthly', + ]) + ->assertSessionHasErrors('plan_id'); +}); + +test('downgrading is denied while the account holds more workspaces than the target allows', function () { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + Workspace::factory()->count(2)->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + subscribeAccount($account); + + $response = Gate::forUser($user)->inspect('swapPlan', [$account->fresh(), $this->socials]); + + expect($response->denied())->toBeTrue() + ->and($response->message())->toBe(__('billing.flash.too_many_workspaces', [ + 'count' => 2, + 'limit' => 1, + ])); +}); + +test('change-plan flashes when the account holds more workspaces than the target allows', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + $withWorkspace($user); + Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + subscribeAccount($account); + + $this->actingAs($user->fresh()) + ->from(route('app.billing.index')) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->socials->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.error', __('billing.flash.too_many_workspaces', [ + 'count' => 2, + 'limit' => 1, + ])); + + expect($account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + +test('downgrading is allowed once the account is inside the target limit', function () { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + subscribeAccount($account); + + $response = Gate::forUser($user)->inspect('swapPlan', [$account->fresh(), $this->socials]); + + expect($response->allowed())->toBeTrue(); +}); + +test('billing lists socials as denied when the account has too many workspaces', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + + $withWorkspace($user); + Workspace::factory()->create([ + 'account_id' => $account->id, + 'user_id' => $user->id, + ]); + + subscribeAccount($account); + + $this->actingAs($user->fresh()) + ->get(route('app.billing.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('settings/account/Billing', false) + ->where('workspaceCount', 2) + ->where('plans.0.id', $this->socials->id) + ->where('plans.0.workspace_limit', 1) + ->has('plans', 2) + ); +}); + +test('plan change actions exist in every locale', function (string $locale) { + expect(__('billing.plans.upgrade', ['plan' => 'Workspaces'], $locale)) + ->not->toBe('billing.plans.upgrade') + ->toContain('Workspaces') + ->and(__('billing.plans.downgrade', ['plan' => 'Socials'], $locale)) + ->not->toBe('billing.plans.downgrade') + ->toContain('Socials'); +})->with(ContentLanguage::values()); + +test('english plan change actions say upgrade and downgrade instead of choose', function () { + expect(__('billing.plans.upgrade', ['plan' => 'Workspaces'])) + ->toBe('Upgrade to Workspaces') + ->and(__('billing.plans.downgrade', ['plan' => 'Socials'])) + ->toBe('Downgrade to Socials') + ->and(__('billing.plans.select', ['plan' => 'Workspaces'])) + ->toBe('Choose Workspaces'); +}); + +test('cloud requests share plans for the workspace upgrade paywall', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + subscribeAccount($account); + + $this->actingAs($user->fresh()) + ->get(route('app.billing.index')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->has('plans', 2) + ->where('workspaceCount', 1) + ->where('plans.0.slug', 'socials') + ->where('plans.1.slug', 'workspaces') + ); +}); + +test('change-plan swaps the price and writes plan_id', function () use ($withWorkspace) { + Cashier::useSubscriptionModel(RecordingSwapSubscription::class); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_socials_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $this->workspaces->name, + ])); + + expect($account->fresh()->plan_id)->toBe($this->workspaces->id) + ->and(RecordingSwapSubscription::$swappedPrices)->toBe(['price_workspaces_monthly']); +}); + +test('change-plan swaps to the yearly price', function () use ($withWorkspace) { + Cashier::useSubscriptionModel(RecordingSwapSubscription::class); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_socials_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'yearly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $this->workspaces->name, + ])); + + expect($account->fresh()->plan_id)->toBe($this->workspaces->id) + ->and(RecordingSwapSubscription::$swappedPrices)->toBe(['price_workspaces_yearly']); +}); + +test('change-plan downgrades workspaces to socials', function () use ($withWorkspace) { + Cashier::useSubscriptionModel(RecordingSwapSubscription::class); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->workspaces->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_workspaces_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->socials->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $this->socials->name, + ])); + + expect($account->fresh()->plan_id)->toBe($this->socials->id) + ->and(RecordingSwapSubscription::$swappedPrices)->toBe(['price_socials_monthly']); +}); + +test('change-plan writes plan_id when the subscription is already on that price', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_workspaces_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $this->workspaces->name, + ])); + + expect($account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + +test('change-plan writes plan_id while the subscription is still trialing', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_workspaces_monthly', + 'trial_ends_at' => now()->addDays(5), + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $this->workspaces->name, + ])); + + expect($account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + +test('change-plan does not write plan_id when the matching price is still past due', function () use ($withWorkspace) { + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'past_due', + 'stripe_price' => 'price_workspaces_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionMissing('flash.success'); + + expect($account->fresh()->plan_id)->toBe($this->socials->id); +}); + +test('change-plan sends incomplete payments to cashier without writing plan_id', function () use ($withWorkspace) { + Cashier::useSubscriptionModel(IncompleteSwapSubscription::class); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_socials_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('cashier.payment', [ + 'id' => 'pi_test_incomplete', + 'redirect' => route('app.billing.index'), + ])); + + expect($account->fresh()->plan_id)->toBe($this->socials->id) + ->and($account->subscription(Account::SUBSCRIPTION_NAME)?->stripe_price)->toBe('price_workspaces_monthly'); +}); + +test('change-plan does not write plan_id when swap fails before stripe saves the price', function () use ($withWorkspace) { + Cashier::useSubscriptionModel(FailingSwapSubscription::class); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => $this->socials->id]); + $withWorkspace($user); + + $account->subscriptions()->create([ + 'type' => Account::SUBSCRIPTION_NAME, + 'stripe_id' => 'sub_'.fake()->uuid(), + 'stripe_status' => 'active', + 'stripe_price' => 'price_socials_monthly', + ]); + + $this->actingAs($user->fresh()) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $this->workspaces->id, + 'interval' => 'monthly', + ]) + ->assertRedirect(route('cashier.payment', [ + 'id' => 'pi_test_incomplete', + 'redirect' => route('app.billing.index'), + ])); + + expect($account->fresh()->plan_id)->toBe($this->socials->id) + ->and($account->subscription(Account::SUBSCRIPTION_NAME)?->stripe_price)->toBe('price_socials_monthly'); +}); + +class RecordingSwapSubscription extends Subscription +{ + protected $table = 'subscriptions'; + + /** + * @var list> + */ + public static array $swappedPrices = []; + + public function getForeignKey(): string + { + return 'subscription_id'; + } + + public function swap(string|array $prices, array $options = []): static + { + self::$swappedPrices[] = $prices; + + return $this; + } +} + +class IncompleteSwapSubscription extends Subscription +{ + protected $table = 'subscriptions'; + + public function getForeignKey(): string + { + return 'subscription_id'; + } + + public function swap(string|array $prices, array $options = []): static + { + $this->fill([ + 'stripe_price' => is_string($prices) ? $prices : null, + ])->save(); + + throw IncompletePayment::requiresAction(self::payment()); + } + + public static function payment(): Payment + { + return new Payment( + StripePaymentIntent::constructFrom([ + 'id' => 'pi_test_incomplete', + 'object' => 'payment_intent', + 'amount' => 8000, + 'currency' => 'usd', + 'client_secret' => 'pi_test_incomplete_secret', + 'status' => StripePaymentIntent::STATUS_REQUIRES_ACTION, + ]), + ); + } +} + +class FailingSwapSubscription extends Subscription +{ + protected $table = 'subscriptions'; + + public function getForeignKey(): string + { + return 'subscription_id'; + } + + public function swap(string|array $prices, array $options = []): static + { + throw IncompletePayment::requiresAction(IncompleteSwapSubscription::payment()); + } +} diff --git a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php b/tests/Feature/Billing/WorkspaceQuantitySyncTest.php deleted file mode 100644 index 4ea23940e..000000000 --- a/tests/Feature/Billing/WorkspaceQuantitySyncTest.php +++ /dev/null @@ -1,77 +0,0 @@ -set('trypost.self_hosted', true); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnTrue(); - $subscription->shouldReceive('updateQuantity')->never(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - $account->shouldReceive('workspaces->count')->andReturn(3); - - $account->syncWorkspaceQuantity(); -}); - -test('syncWorkspaceQuantity does not touch Stripe without an active subscription', function () { - config()->set('trypost.self_hosted', false); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnFalse(); - $subscription->shouldReceive('updateQuantity')->never(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - - $account->syncWorkspaceQuantity(); -}); - -test('syncWorkspaceQuantity updates the subscription quantity to the workspace count', function () { - config()->set('trypost.self_hosted', false); - - $subscription = mock(Subscription::class); - $subscription->shouldReceive('active')->andReturnTrue(); - $subscription->shouldReceive('updateQuantity')->once()->with(3); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('subscription')->with(Account::SUBSCRIPTION_NAME)->andReturn($subscription); - $account->shouldReceive('workspaces->count')->andReturn(3); - - $account->syncWorkspaceQuantity(); -}); - -test('creating a workspace syncs the stripe quantity', function () { - $user = User::factory()->create(); - - $account = mock(Account::class)->makePartial(); - $account->shouldReceive('syncWorkspaceQuantity')->once(); - $user->setRelation('account', $account); - - CreateWorkspace::execute($user, ['name' => 'Wiring']); -}); - -test('deleting a workspace syncs the stripe quantity', function () { - config()->set('trypost.self_hosted', true); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $account = Mockery::mock($user->account)->makePartial(); - $account->shouldReceive('syncWorkspaceQuantity')->once(); - $workspace->setRelation('account', $account); - - expect(DeleteWorkspace::execute($workspace))->toBeTrue(); -}); diff --git a/tests/Feature/BillingControllerTest.php b/tests/Feature/BillingControllerTest.php index 600025560..f81d55131 100644 --- a/tests/Feature/BillingControllerTest.php +++ b/tests/Feature/BillingControllerTest.php @@ -2,16 +2,11 @@ declare(strict_types=1); -use App\Enums\PostHog\OnboardingEvent; use App\Enums\UserWorkspace\Role; -use App\Jobs\PostHog\SendEvent; use App\Models\Account; use App\Models\Plan; -use App\Models\Post; -use App\Models\SocialAccount; use App\Models\User; use App\Models\Workspace; -use Illuminate\Support\Facades\Bus; beforeEach(function () { config(['trypost.billing.require_card_for_trial' => true]); @@ -44,11 +39,14 @@ $response->assertRedirect(route('app.welcome.persona')); }); -test('swapToYearly redirects to calendar in self hosted mode', function () { +test('changePlan redirects to calendar in self hosted mode', function () { config(['trypost.self_hosted' => true]); $response = $this->actingAs($this->user) - ->post(route('app.billing.swap-to-yearly')); + ->post(route('app.billing.change-plan'), [ + 'plan_id' => Plan::where('slug', 'socials')->value('id'), + 'interval' => 'yearly', + ]); $response->assertRedirect(route('app.calendar')); }); @@ -153,78 +151,29 @@ $response->assertInertia(fn ($page) => $page ->component('billing/Processing', false) ->has('subscriptionActive') - ->where('redirectToOnboarding', true) + ->missing('redirectToOnboarding') ); }); -test('billing processing skips onboarding when already completed', function () { +test('billing processing keeps auth.plan null until the webhook writes plan_id', function () { config(['trypost.self_hosted' => false]); - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - $this->actingAs($this->user->fresh()) - ->get(route('app.billing.processing')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false)); -}); - -test('billing processing skips onboarding when dismissed', function () { - config(['trypost.self_hosted' => false]); - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.billing.processing')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false)); -}); - -test('billing processing does not send members to onboarding', function () { - config(['trypost.self_hosted' => false]); - - $member = User::factory()->create(['account_id' => $this->account->id]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($member->fresh()) - ->get(route('app.billing.processing')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('redirectToOnboarding', false)); -}); - -test('billing processing still sends satisfied-but-unstamped owners to onboarding', function () { - config(['trypost.self_hosted' => false]); + $this->account->update(['plan_id' => null]); $this->account->subscriptions()->create([ 'type' => Account::SUBSCRIPTION_NAME, 'stripe_id' => 'sub_test_'.fake()->uuid(), 'stripe_status' => 'active', - 'stripe_price' => 'price_test', - 'quantity' => 1, + 'stripe_price' => 'price_123', ]); - mcpAccessToken($this->user, mcpOauthClient(), $this->workspace); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - Bus::fake(); - // Processing no longer stamps — the owner finishes via /onboarding Continue. $this->actingAs($this->user->fresh()) ->get(route('app.billing.processing')) ->assertOk() ->assertInertia(fn ($page) => $page + ->component('billing/Processing', false) ->where('subscriptionActive', true) - ->where('redirectToOnboarding', true) + ->where('auth.plan', null) ); - - expect($this->account->fresh()->onboarding_completed_at)->toBeNull(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value, - ); }); test('shared auth.plan exposes name slug and interval via AuthPlanResource', function () { @@ -298,8 +247,7 @@ $this->actingAs($member)->get(route('app.billing.index'))->assertForbidden(); }); -// Swap-to-yearly tests -test('swapToYearly forbids a non-owner', function () { +test('changePlan forbids a non-owner', function () { config(['trypost.self_hosted' => false]); $member = User::factory()->create(['account_id' => $this->account->id]); @@ -314,14 +262,17 @@ ]); $this->actingAs($member) - ->post(route('app.billing.swap-to-yearly')) + ->post(route('app.billing.change-plan'), [ + 'plan_id' => Plan::where('slug', 'socials')->value('id'), + 'interval' => 'yearly', + ]) ->assertForbidden(); }); -test('swapToYearly is a no-op when already on annual billing', function () { +test('changePlan writes plan_id and flashes when already on that price', function () { config(['trypost.self_hosted' => false]); - $plan = Plan::where('slug', 'workspace')->first(); + $plan = Plan::where('slug', 'socials')->first(); $plan->update([ 'stripe_monthly_price_id' => 'price_monthly', 'stripe_yearly_price_id' => 'price_yearly', @@ -337,12 +288,20 @@ $this->user->unsetRelation('account'); $this->actingAs($this->user) - ->post(route('app.billing.swap-to-yearly')) - ->assertRedirect(route('app.billing.index')); + ->post(route('app.billing.change-plan'), [ + 'plan_id' => $plan->id, + 'interval' => 'yearly', + ]) + ->assertRedirect(route('app.billing.index')) + ->assertSessionHas('flash.success', __('billing.flash.plan_changed', [ + 'plan' => $plan->name, + ])); + + expect($this->account->fresh()->plan_id)->toBe($plan->id); }); -test('swapToYearly requires authentication', function () { - $response = $this->post(route('app.billing.swap-to-yearly')); +test('changePlan requires authentication', function () { + $response = $this->post(route('app.billing.change-plan')); $response->assertRedirect(route('login')); }); diff --git a/tests/Feature/Commands/RefreshExpiringTokensTest.php b/tests/Feature/Commands/RefreshExpiringTokensTest.php index f4340c5ee..a99de777e 100644 --- a/tests/Feature/Commands/RefreshExpiringTokensTest.php +++ b/tests/Feature/Commands/RefreshExpiringTokensTest.php @@ -10,7 +10,6 @@ use Illuminate\Support\Facades\Queue; test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () { - config()->set('trypost.allow_multiple_social_accounts', true); Queue::fake(); $workspace = Workspace::factory()->create(); diff --git a/tests/Feature/HelpMenuTest.php b/tests/Feature/HelpMenuTest.php new file mode 100644 index 000000000..98c2097f2 --- /dev/null +++ b/tests/Feature/HelpMenuTest.php @@ -0,0 +1,34 @@ +not->toBe('sidebar.support.chat') + ->not->toBeEmpty(); +})->with(ContentLanguage::values()); + +test('english chat support copy is chat support', function () { + expect(__('sidebar.support.chat'))->toBe('Chat support'); +}); + +test('community group copy exists in every locale', function (string $locale) { + expect(__('sidebar.support.community', [], $locale)) + ->not->toBe('sidebar.support.community') + ->not->toBeEmpty(); +})->with(ContentLanguage::values()); + +test('english community group copy is community', function () { + expect(__('sidebar.support.community'))->toBe('Community'); +}); + +test('help menu opens crisp only on cloud', function () { + $source = file_get_contents(resource_path('js/components/HelpMenu.vue')); + + expect($source) + ->toContain('page.props.selfHosted === false') + ->toContain("['do', 'chat:show']") + ->toContain("['do', 'chat:open']"); +}); diff --git a/tests/Feature/InstagramFacebookIntegrationTest.php b/tests/Feature/InstagramFacebookIntegrationTest.php index 5ca5ce909..0c2d7f19b 100644 --- a/tests/Feature/InstagramFacebookIntegrationTest.php +++ b/tests/Feature/InstagramFacebookIntegrationTest.php @@ -128,7 +128,6 @@ }); test('instagram standalone publisher uses graph.instagram.com', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $standaloneAccount = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram, diff --git a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php index 8b0935688..e17dca94a 100644 --- a/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php +++ b/tests/Feature/Jobs/PostHog/SyncAccountUsageTest.php @@ -47,7 +47,6 @@ 'account_id' => $this->account->id, 'user_id' => $this->user->id, ]); - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->count(2)->create(['workspace_id' => $workspace->id]); Post::factory()->count(3)->create([ 'workspace_id' => $workspace->id, diff --git a/tests/Feature/Jobs/PostHog/TrackPostTest.php b/tests/Feature/Jobs/PostHog/TrackPostTest.php index 8506e3971..350042a8e 100644 --- a/tests/Feature/Jobs/PostHog/TrackPostTest.php +++ b/tests/Feature/Jobs/PostHog/TrackPostTest.php @@ -17,11 +17,7 @@ beforeEach(function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); - $this->account = Account::factory()->create([ - // Avoid PostObserver onboarding broadcasts emitting step_completed - // SendEvent jobs that collide with TrackPost assertions. - 'onboarding_completed_at' => now(), - ]); + $this->account = Account::factory()->create(); $this->user = User::factory()->create(['account_id' => $this->account->id]); $this->account->update(['owner_id' => $this->user->id]); $this->workspace = Workspace::factory()->create([ diff --git a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php index 7b9e60cc5..565bb5adb 100644 --- a/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php +++ b/tests/Feature/Jobs/VerifyUpcomingPostConnectionsTest.php @@ -1010,7 +1010,6 @@ function verifyUpcomingSelectsFrom(string $sql, string $table): bool }); test('still verifies and warns about an active account when another account in the same workspace is paused', function () { - config()->set('trypost.allow_multiple_social_accounts', true); Mail::fake(); $workspace = Workspace::factory()->create(); diff --git a/tests/Feature/Listeners/StripeEventListenerTest.php b/tests/Feature/Listeners/StripeEventListenerTest.php index 4ec586c4d..839ee3a11 100644 --- a/tests/Feature/Listeners/StripeEventListenerTest.php +++ b/tests/Feature/Listeners/StripeEventListenerTest.php @@ -26,6 +26,18 @@ 'stripe_yearly_price_id' => 'price_workspace_yearly', ]); + $this->socials = Plan::where('slug', Slug::Socials)->firstOrFail(); + $this->socials->update([ + 'stripe_monthly_price_id' => 'price_socials_monthly', + 'stripe_yearly_price_id' => 'price_socials_yearly', + ]); + + $this->workspaces = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $this->workspaces->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); + $this->account = Account::factory()->create(['stripe_id' => 'cus_test123']); $this->user = User::factory()->create([ 'account_id' => $this->account->id, @@ -268,6 +280,7 @@ 'type' => 'customer.subscription.created', 'data' => ['object' => [ 'customer' => 'cus_test123', + 'status' => 'active', 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); @@ -275,6 +288,36 @@ expect($this->account->fresh()->plan_id)->toBe($this->plan->id); }); +test('subscription created does not sync a plan while payment is incomplete', function () { + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'incomplete', + 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBeNull(); +}); + +test('subscription created syncs the workspaces plan while trialing', function () { + $this->account->update(['plan_id' => null]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.created', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'trialing', + 'items' => ['data' => [['price' => ['id' => 'price_workspaces_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + test('subscription updated maps the plan by its monthly price id', function () { $this->account->update(['plan_id' => null]); @@ -282,6 +325,7 @@ 'type' => 'customer.subscription.updated', 'data' => ['object' => [ 'customer' => 'cus_test123', + 'status' => 'active', 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); @@ -296,6 +340,7 @@ 'type' => 'customer.subscription.updated', 'data' => ['object' => [ 'customer' => 'cus_test123', + 'status' => 'trialing', 'items' => ['data' => [['price' => ['id' => 'price_workspace_yearly']]]], ]], ])); @@ -303,6 +348,85 @@ expect($this->account->fresh()->plan_id)->toBe($this->plan->id); }); +test('subscription updated does not sync a richer plan while past due', function () { + $this->account->update(['plan_id' => $this->socials->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'past_due', + 'items' => ['data' => [['price' => ['id' => 'price_workspaces_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->socials->id); +}); + +test('subscription updated does not clear workspaces while past due', function () { + $this->account->update(['plan_id' => $this->workspaces->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'past_due', + 'items' => ['data' => [['price' => ['id' => 'price_workspaces_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + +test('subscription updated overwrites socials with workspaces when active', function () { + $this->account->update(['plan_id' => $this->socials->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'active', + 'items' => ['data' => [['price' => ['id' => 'price_workspaces_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->workspaces->id); +}); + +test('subscription updated overwrites workspaces with socials when active', function () { + $this->account->update(['plan_id' => $this->workspaces->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => 'active', + 'items' => ['data' => [['price' => ['id' => 'price_socials_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBe($this->socials->id); +}); + +test('subscription updated clears the plan when payment is unpaid canceled or expired', function (string $status) { + $this->account->update(['plan_id' => $this->workspaces->id]); + + $this->listener->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'customer' => 'cus_test123', + 'status' => $status, + 'items' => ['data' => [['price' => ['id' => 'price_workspaces_monthly']]]], + ]], + ])); + + expect($this->account->fresh()->plan_id)->toBeNull(); +})->with([ + 'unpaid' => ['unpaid'], + 'canceled' => ['canceled'], + 'incomplete expired' => ['incomplete_expired'], +]); + test('subscription updated leaves plan_id alone when the price already matches', function () { $this->account->update(['plan_id' => $this->plan->id]); @@ -310,6 +434,7 @@ 'type' => 'customer.subscription.updated', 'data' => ['object' => [ 'customer' => 'cus_test123', + 'status' => 'active', 'items' => ['data' => [['price' => ['id' => 'price_workspace_monthly']]]], ]], ])); diff --git a/tests/Feature/Mcp/OAuthAuthorizeAuthTokenTest.php b/tests/Feature/Mcp/OAuthAuthorizeAuthTokenTest.php index 2a94c7fee..17f5a356d 100644 --- a/tests/Feature/Mcp/OAuthAuthorizeAuthTokenTest.php +++ b/tests/Feature/Mcp/OAuthAuthorizeAuthTokenTest.php @@ -33,30 +33,12 @@ ]); }); -/** - * Happy path: mid-activation owners must get onboardingProgress inline (false), not - * deferred. Deferred props re-request /oauth/authorize and rotate Passport's authToken - * while the consent page still holds the old value. - */ -test('mid-activation owner oauth consent shares onboarding progress inline instead of deferring', function () { - $this->actingAs($this->user) - ->get(route('passport.authorizations.authorize', oauthAuthorizeQuery($this->clientId))) - ->assertOk() - ->assertInertia(fn (AssertableInertia $page) => $page - ->component('mcp/Authorize') - // Must be present as false — missing() would mean Inertia::defer() came back. - ->where('onboardingProgress', false) - ->has('authToken') - ); -}); - -test('mid-activation owner can approve oauth consent with the auth token from the consent page', function () { +test('account owner can approve oauth consent with the auth token from the consent page', function () { $this->actingAs($this->user) ->get(route('passport.authorizations.authorize', oauthAuthorizeQuery($this->clientId))) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('mcp/Authorize') - ->where('onboardingProgress', false) ->has('authToken') ); @@ -86,7 +68,6 @@ ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('mcp/Authorize') - ->where('onboardingProgress', false) ->has('authToken') ); diff --git a/tests/Feature/Mcp/PostToolTest.php b/tests/Feature/Mcp/PostToolTest.php index a4d5f5d61..a92ba8866 100644 --- a/tests/Feature/Mcp/PostToolTest.php +++ b/tests/Feature/Mcp/PostToolTest.php @@ -221,7 +221,6 @@ }); test('create post rejects an inactive social account', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $inactive = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn, diff --git a/tests/Feature/Models/HasUsageTraitTest.php b/tests/Feature/Models/HasUsageTraitTest.php index 9d0b48dbf..e5391937b 100644 --- a/tests/Feature/Models/HasUsageTraitTest.php +++ b/tests/Feature/Models/HasUsageTraitTest.php @@ -24,7 +24,6 @@ 'user_id' => $this->owner->id, ]); $workspace = $this->account->workspaces()->first(); - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->count(3)->create(['workspace_id' => $workspace->id]); User::factory()->count(2)->create(['account_id' => $this->account->id]); @@ -45,20 +44,36 @@ ]); }); -test('featureLimits returns the monthly credits allotment', function () { - $plan = Plan::where('slug', Slug::Workspace)->first(); - $this->account->update(['plan_id' => $plan->id]); +test('featureLimits reports the plan workspace limit', function () { + config()->set('trypost.self_hosted', false); - Workspace::factory()->count(2)->create([ - 'account_id' => $this->account->id, - 'user_id' => $this->owner->id, - ]); + $user = User::factory()->create(); + $account = $user->account; - $limits = $this->account->featureLimits(); + $account->update(['plan_id' => Plan::where('slug', Slug::Socials)->value('id')]); - expect($limits)->toBe([ - 'monthlyCreditsLimit' => 5000, - ]); + expect($account->fresh()->featureLimits())->toBe(['workspaceLimit' => 1]); +}); + +test('featureLimits reports a one-workspace cap when the account has no plan', function () { + config()->set('trypost.self_hosted', false); + + $user = User::factory()->create(); + $account = $user->account; + $account->update(['plan_id' => null]); + + expect($account->fresh()->featureLimits())->toBe(['workspaceLimit' => 1]); +}); + +test('featureLimits reports null for an unlimited plan', function () { + config()->set('trypost.self_hosted', false); + + $user = User::factory()->create(); + $account = $user->account; + + $account->update(['plan_id' => Plan::where('slug', Slug::Workspaces)->value('id')]); + + expect($account->fresh()->featureLimits())->toBe(['workspaceLimit' => null]); }); test('pendingInviteCount excludes accepted invites', function () { diff --git a/tests/Feature/Observers/SocialAccountObserverTest.php b/tests/Feature/Observers/SocialAccountObserverTest.php index 376f22791..bbf697716 100644 --- a/tests/Feature/Observers/SocialAccountObserverTest.php +++ b/tests/Feature/Observers/SocialAccountObserverTest.php @@ -126,7 +126,6 @@ }); test('updating status on multiple batch-hydrated social accounts does not throw a lazy loading violation', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $accounts = SocialAccount::factory()->count(2)->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Onboarding/OnboardingControllerTest.php b/tests/Feature/Onboarding/OnboardingControllerTest.php deleted file mode 100644 index e2bfbb57f..000000000 --- a/tests/Feature/Onboarding/OnboardingControllerTest.php +++ /dev/null @@ -1,644 +0,0 @@ - false, - 'services.posthog.enabled' => true, - 'services.posthog.api_key' => 'phc_test', - ]); - - Bus::fake(); - - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); - $this->user->refresh(); - - subscribeAccount($this->user->account); -}); - -test('onboarding renders activation status and connection props', function () { - $socialAccount = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ]); - - $this->actingAs($this->user) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('onboarding/Index', false) - ->where('status.mcp_connected', false) - ->where('status.social_connected', true) - ->where('status.first_post_created', false) - ->where('status.all_complete', false) - ->where('status.show_progress', true) - ->where('status.completed_at', null) - ->where('status.dismissed_at', null) - ->where('mcpUrl', route('mcp.trypost')) - ->where('canSkipSteps', true) - ->where('canManageAccounts', true) - ->where('canCreatePost', true) - ->missing('mcpClients') - ->missing('samplePrompt') - ->has('platforms', collect(Platform::cases())->filter->isConnectable()->count()) - ->where('accounts.0.id', $socialAccount->id) - ->where('auth.user.first_name', $this->user->firstName()) - ); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' - && data_get($event->payload, 'distinctId') === $this->user->id - && data_get($event->payload, 'event') === OnboardingEvent::Viewed->value); -}); - -test('onboarding flags the social step when only a sibling workspace is connected', function () { - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - SocialAccount::factory()->create(['workspace_id' => $otherWorkspace->id]); - - $this->actingAs($this->user) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('status.social_connected', true) - ->where('accounts', []) - ->missing('socialConnectedElsewhere') - ); -}); - -test('onboarding does not flag social elsewhere when the current workspace is connected', function () { - SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); - - $this->actingAs($this->user) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('status.social_connected', true) - ->missing('socialConnectedElsewhere') - ); -}); - -test('onboarding partial reload refreshes status and accounts', function () { - $response = $this->actingAs($this->user) - ->get(route('app.onboarding')) - ->assertOk(); - - $response->assertInertia(fn ($page) => $page - ->reloadOnly(['status', 'accounts', 'onboardingProgress'], fn ($reload) => $reload - ->has('status') - ->has('accounts') - ) - ); -}); - -test('a member visit does not capture onboarding viewed', function () { - Bus::fake(); - - $member = User::factory()->create([ - 'account_id' => $this->user->account_id, - 'current_workspace_id' => $this->workspace->id, - ]); - $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); - - $this->actingAs($member->fresh()) - ->get(route('app.onboarding')) - ->assertOk(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); - - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertOk(); - - Bus::assertDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); -}); - -test('onboarding does not capture viewed when the checklist is already complete', function () { - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('status.all_complete', true) - ->where('status.completed_at', null)); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value, - ); -}); - -test('owner can skip the mcp step', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - $this->actingAs($this->user) - ->post(route('app.onboarding.mcp.skip')) - ->assertRedirect(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBe(['mcp']); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::StepSkipped->value - && data_get($event->payload, 'properties.step') === 'mcp'); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id, - ); -}); - -test('skipping the last open step completes the onboarding', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $this->actingAs($this->user) - ->post(route('app.onboarding.mcp.skip')) - ->assertRedirect(); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value); -}); - -test('skipping an already connected step is a no-op', function () { - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.mcp.skip')) - ->assertRedirect(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBeNull(); -}); - -test('skipping the same step twice is a no-op', function () { - $this->actingAs($this->user) - ->post(route('app.onboarding.mcp.skip')); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.mcp.skip')) - ->assertRedirect(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBe(['mcp']); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::StepSkipped->value, - ); -}); - -test('dismissed accounts are redirected away from onboarding index', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertRedirect(route('app.calendar')); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); -}); - -test('completed accounts can still view the finished onboarding checklist', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('onboarding/Index', false) - ->where('status.all_complete', true) - ->where('status.completed_at', now()->toIso8601String()) - ); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); -}); - -test('partial reload refreshes status and accounts while onboarding is open', function () { - $response = $this->actingAs($this->user) - ->get(route('app.onboarding')) - ->assertOk(); - - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - - $response->assertInertia(fn ($page) => $page - ->reloadOnly(['status', 'accounts', 'onboardingProgress'], fn ($reload) => $reload - ->where('status.social_connected', true) - ->has('accounts', 1) - ->missing('platforms') - ) - ); -}); - -test('ready state renders without stamping completion on GET', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - Bus::fake(); - - // Steps are done but completed_at stays null until POST complete / observers. - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('onboarding/Index', false) - ->where('status.all_complete', true) - ->where('status.completed_at', null) - ); - - expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Viewed->value, - ); - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value, - ); - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::StepCompleted->value, - ); -}); - -test('skip step after completion is a no-op', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.mcp.skip')) - ->assertRedirect(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBeNull(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::StepSkipped->value, - ); - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('onboarding cannot be completed before every activation step', function () { - $this->actingAs($this->user) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.onboarding')); - - expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); - - Bus::assertNotDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value); -}); - -test('onboarding completes after every activation step', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.onboarding')); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id, - ); -}); - -test('onboarding complete does not re-fire completed when already stamped', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.onboarding')); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value, - ); -}); - -test('members do not see the skip control', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($member->id, [ - 'role' => Role::Member->value, - ]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($member->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('canSkipSteps', false) - ->where('canManageAccounts', false) - ->where('canCreatePost', true)); -}); - -test('viewers cannot manage social accounts or create posts from onboarding', function () { - $viewer = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($viewer->id, [ - 'role' => Role::Viewer->value, - ]); - $viewer->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($viewer->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->where('canSkipSteps', false) - ->where('canManageAccounts', false) - ->where('canCreatePost', false) - // MCP setup stays visible even without createPost — only write CTAs are gated. - ->where('mcpUrl', route('mcp.trypost'))); -}); - -test('only the account owner can skip onboarding steps', function (Role $role) { - $teammate = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($teammate->id, [ - 'role' => $role->value, - ]); - $teammate->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($teammate->fresh()) - ->post(route('app.onboarding.mcp.skip')) - ->assertForbidden(); - - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBeNull(); -})->with([ - 'member' => Role::Member, - 'viewer' => Role::Viewer, -]); - -test('teammates cannot stamp completion via the complete endpoint', function (Role $role) { - Carbon::setTestNow('2026-07-29 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $teammate = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($teammate->id, [ - 'role' => $role->value, - ]); - $teammate->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($teammate->fresh()) - ->post(route('app.onboarding.complete')) - ->assertForbidden(); - - expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -})->with([ - 'member' => Role::Member, - 'viewer' => Role::Viewer, -]); - -test('owner stamps completion via the complete endpoint for every workspace', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.onboarding')); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $this->workspace->id, - ); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $otherWorkspace->id, - ); -}); - -test('complete stamps when activation finished on another workspace', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - Event::fake([OnboardingStatusUpdated::class]); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - - $emptyWorkspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $emptyWorkspace->id]); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.onboarding')); - - expect($this->user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('complete after dismiss redirects to calendar without stamping completion', function () { - Carbon::setTestNow('2026-07-24 12:00:00'); - - AccessToken::withoutEvents(fn () => mcpAccessToken($this->user, mcpOauthClient(), $this->workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'user_id' => $this->user->id, - ])); - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - Bus::fake(); - - $this->actingAs($this->user->fresh()) - ->post(route('app.onboarding.complete')) - ->assertRedirect(route('app.calendar')); - - expect($this->user->account->fresh()->onboarding_completed_at)->toBeNull(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === OnboardingEvent::Completed->value, - ); -}); - -test('unsubscribed accounts are redirected to welcome by middleware', function (string $routeName, string $method, array|string $params = []) { - $this->user->account->subscriptions()->delete(); - $this->actingAs($this->user->fresh()); - - $response = $method === 'get' - ? $this->get(route($routeName, $params)) - : $this->post(route($routeName, $params)); - - $response->assertRedirect(route('app.welcome.persona')); -})->with([ - 'index' => ['app.onboarding', 'get'], - 'skip step' => ['app.onboarding.mcp.skip', 'post'], - 'complete' => ['app.onboarding.complete', 'post'], -]); - -test('self hosted activation endpoints remain available', function (string $routeName, string $method, array|string $params = []) { - config(['trypost.self_hosted' => true]); - $this->actingAs($this->user); - - $response = $method === 'get' - ? $this->get(route($routeName, $params)) - : $this->post(route($routeName, $params)); - - if ($method === 'get') { - $response->assertOk() - ->assertInertia(fn ($page) => $page->component('onboarding/Index', false)); - - return; - } - - // Must not bounce to calendar (the old self-hosted gate). - expect($response->headers->get('Location'))->not->toBe(route('app.calendar')); - - if ($routeName === 'app.onboarding.mcp.skip') { - $response->assertRedirect(); - expect($this->user->account->fresh()->onboarding_skipped_steps)->toBe(['mcp']); - - return; - } - - // Incomplete checklist: stay on onboarding (same as SaaS). - $response->assertRedirect(route('app.onboarding')); -})->with([ - 'index' => ['app.onboarding', 'get'], - 'skip step' => ['app.onboarding.mcp.skip', 'post'], - 'complete' => ['app.onboarding.complete', 'post'], -]); - -test('self hosted owners can open onboarding without a subscription', function () { - config(['trypost.self_hosted' => true]); - $this->user->account->subscriptions()->delete(); - - expect($this->user->account->fresh()->hasAppAccess())->toBeTrue(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.onboarding')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('onboarding/Index', false) - ->where('status.show_progress', true) - ); -}); diff --git a/tests/Feature/Onboarding/OnboardingProgressShareTest.php b/tests/Feature/Onboarding/OnboardingProgressShareTest.php deleted file mode 100644 index cd9b5ba2f..000000000 --- a/tests/Feature/Onboarding/OnboardingProgressShareTest.php +++ /dev/null @@ -1,140 +0,0 @@ - false]); - - $this->user = User::factory()->create(); - $this->workspace = Workspace::factory()->create([ - 'account_id' => $this->user->account_id, - 'user_id' => $this->user->id, - ]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); - $this->user->refresh(); - - subscribeAccount($this->user->account); -}); - -test('defers the onboarding checklist progress for mid-activation owners', function (string $routeName) { - $this->actingAs($this->user) - ->get(route($routeName)) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->missing('onboardingProgress') - ->loadDeferredProps(fn ($page) => $page - ->where('onboardingProgress.completed', 0) - ->where('onboardingProgress.total', ResolveOnboardingStatus::totalSteps()) - ) - ); -})->with([ - 'calendar' => 'app.calendar', - 'onboarding' => 'app.onboarding', - 'mcp settings' => 'app.mcp.index', -]); - -test('does not share the onboarding progress state after dismissal', function () { - $this->user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.calendar')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('onboardingProgress', false)); -}); - -test('does not share the onboarding progress state after completion', function () { - $this->user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - $this->actingAs($this->user->fresh()) - ->get(route('app.calendar')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('onboardingProgress', false)); -}); - -test('does not share the onboarding progress with workspace members', function () { - $member = User::factory()->create(['account_id' => $this->user->account_id]); - $this->workspace->members()->attach($member->id, [ - 'role' => Role::Member->value, - ]); - $member->update(['current_workspace_id' => $this->workspace->id]); - - $this->actingAs($member->fresh()) - ->get(route('app.calendar')) - ->assertOk() - ->assertInertia(fn ($page) => $page->where('onboardingProgress', false)); -}); - -test('defers the onboarding progress in self-hosted mode', function () { - config(['trypost.self_hosted' => true]); - - $this->actingAs($this->user) - ->get(route('app.calendar')) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->missing('onboardingProgress') - ->loadDeferredProps(fn ($page) => $page - ->where('onboardingProgress.completed', 0) - ->where('onboardingProgress.total', ResolveOnboardingStatus::totalSteps()) - ) - ); -}); - -test('does not defer onboarding progress on passport oauth consent', function () { - $clientId = mcpOauthClient('Consent Share Agent'); - DB::table('oauth_clients')->where('id', $clientId)->update([ - 'redirect_uris' => json_encode(['https://client.example/callback']), - ]); - - $this->actingAs($this->user) - ->get(route('passport.authorizations.authorize', oauthAuthorizeQuery($clientId))) - ->assertOk() - ->assertInertia(fn ($page) => $page - ->component('mcp/Authorize') - // Inline false — never missing/deferred on this URL (see OAuthAuthorizeAuthTokenTest). - ->where('onboardingProgress', false) - ); -}); - -test('does not defer onboarding progress on passport device consent view', function () { - // Device consent uses Passport's Blade view (not Inertia mcp/Authorize), so assert - // the shared prop directly — same authToken rotation risk as the code authorize GET. - expect(app(ResolveOnboardingStatus::class)->canShowProgress($this->user))->toBeTrue(); - - $deviceRequest = Request::create('/oauth/device/authorize', 'GET'); - $deviceRequest->setLaravelSession(app('session.store')); - $deviceRequest->setUserResolver(fn (): User => $this->user); - $deviceRequest->setRouteResolver(function (): Route { - $route = new Route(['GET'], '/oauth/device/authorize', fn () => null); - $route->name('passport.device.authorizations.authorize'); - - return $route; - }); - - $deviceShared = app(HandleInertiaRequests::class)->share($deviceRequest); - - expect($deviceShared['onboardingProgress'])->toBeFalse(); - - $calendarRequest = Request::create('/calendar', 'GET'); - $calendarRequest->setLaravelSession(app('session.store')); - $calendarRequest->setUserResolver(fn (): User => $this->user); - $calendarRequest->setRouteResolver(function (): Route { - $route = new Route(['GET'], '/calendar', fn () => null); - $route->name('app.calendar'); - - return $route; - }); - - $calendarShared = app(HandleInertiaRequests::class)->share($calendarRequest); - - expect($calendarShared['onboardingProgress'])->toBeInstanceOf(DeferProp::class); -}); diff --git a/tests/Feature/PlanSeederTest.php b/tests/Feature/PlanSeederTest.php index 37167fc38..a8acd9d4d 100644 --- a/tests/Feature/PlanSeederTest.php +++ b/tests/Feature/PlanSeederTest.php @@ -6,23 +6,34 @@ use App\Models\Plan; use Database\Seeders\PlanSeeder; -test('seeder creates only the per-workspace plan', function () { - expect(Plan::count())->toBe(1); - - $workspace = Plan::where('slug', Slug::Workspace)->first(); - - expect($workspace->name)->toBe('Workspace') - ->and($workspace->monthly_credits_limit)->toBe(2500) - ->and($workspace->is_archived)->toBeFalse(); +test('seeder creates the two active plans and the archived legacy plan', function () { + expect(Plan::count())->toBe(3); + + $socials = Plan::where('slug', Slug::Socials)->first(); + $workspaces = Plan::where('slug', Slug::Workspaces)->first(); + $legacy = Plan::where('slug', Slug::Workspace)->first(); + + expect($socials->name)->toBe('Socials') + ->and($socials->workspace_limit)->toBe(1) + ->and($socials->is_archived)->toBeFalse() + ->and($workspaces->name)->toBe('Workspaces') + ->and($workspaces->workspace_limit)->toBeNull() + ->and($workspaces->is_archived)->toBeFalse() + ->and($legacy->name)->toBe('Workspace') + ->and($legacy->workspace_limit)->toBe(1) + ->and($legacy->is_archived)->toBeTrue(); }); test('seeder is idempotent', function () { $this->seed(PlanSeeder::class); - expect(Plan::count())->toBe(1); + expect(Plan::count())->toBe(3); }); -test('the per-workspace plan is active', function () { - expect(Plan::active()->count())->toBe(1) - ->and(Plan::active()->first()->slug)->toBe(Slug::Workspace); +test('only the two new plans are active, ordered by sort', function () { + $active = Plan::active()->orderBy('sort')->get(); + + expect($active)->toHaveCount(2) + ->and($active->first()->slug)->toBe(Slug::Socials) + ->and($active->last()->slug)->toBe(Slug::Workspaces); }); diff --git a/tests/Feature/PlanTest.php b/tests/Feature/PlanTest.php index 032d26453..ebe523b33 100644 --- a/tests/Feature/PlanTest.php +++ b/tests/Feature/PlanTest.php @@ -30,15 +30,21 @@ test('active scope excludes archived plans', function () { $activeBefore = Plan::active()->count(); - $plan = Plan::where('slug', Slug::Workspace)->first(); + $plan = Plan::where('slug', Slug::Socials)->first(); $plan->update(['is_archived' => true]); expect(Plan::active()->count())->toBe($activeBefore - 1); }); test('integer fields are cast correctly', function () { - $plan = Plan::where('slug', Slug::Workspace)->first(); + $plan = Plan::where('slug', Slug::Socials)->first(); - expect($plan->monthly_credits_limit)->toBeInt() + expect($plan->workspace_limit)->toBeInt() ->and($plan->sort)->toBeInt(); }); + +test('an unlimited plan stores a null workspace limit', function () { + $plan = Plan::where('slug', Slug::Workspaces)->first(); + + expect($plan->workspace_limit)->toBeNull(); +}); diff --git a/tests/Feature/Repurpose/AccountHealthTest.php b/tests/Feature/Repurpose/AccountHealthTest.php index 5dbb6ab98..48f393ea4 100644 --- a/tests/Feature/Repurpose/AccountHealthTest.php +++ b/tests/Feature/Repurpose/AccountHealthTest.php @@ -573,7 +573,6 @@ function healthDestination(Workspace $workspace): array }); test('a supported content type survives a platform change untouched', function () { - config()->set('trypost.allow_multiple_social_accounts', true); [$workspace, $user, $source] = healthWorkspace(); diff --git a/tests/Feature/Repurpose/ActionsTest.php b/tests/Feature/Repurpose/ActionsTest.php index 2928dd317..51d498053 100644 --- a/tests/Feature/Repurpose/ActionsTest.php +++ b/tests/Feature/Repurpose/ActionsTest.php @@ -93,7 +93,6 @@ function tiktokDestination(Workspace $workspace): array }); test('two accounts on the same network each get their own repurpose', function () { - config()->set('trypost.allow_multiple_social_accounts', true); [$workspace, $user, $first] = repurposeWorkspace(); $second = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); @@ -241,7 +240,6 @@ function tiktokDestination(Workspace $workspace): array }); test('changing the source account resets the watermark', function () { - config()->set('trypost.allow_multiple_social_accounts', true); [$workspace, $user, $account] = repurposeWorkspace(); diff --git a/tests/Feature/Repurpose/ProcessItemTest.php b/tests/Feature/Repurpose/ProcessItemTest.php index 51da823d6..3b3f90ef7 100644 --- a/tests/Feature/Repurpose/ProcessItemTest.php +++ b/tests/Feature/Repurpose/ProcessItemTest.php @@ -36,7 +36,6 @@ function repurposeWithTwoDestinations(): RepurposeItem { Storage::fake(); - config()->set('trypost.allow_multiple_social_accounts', true); $workspace = Workspace::factory()->create(); $source = SocialAccount::factory()->for($workspace)->create(['platform' => Platform::Instagram]); diff --git a/tests/Feature/Repurpose/RepurposeModelTest.php b/tests/Feature/Repurpose/RepurposeModelTest.php index bf16bb955..d2260e14e 100644 --- a/tests/Feature/Repurpose/RepurposeModelTest.php +++ b/tests/Feature/Repurpose/RepurposeModelTest.php @@ -56,7 +56,6 @@ }); test('a workspace can have one repurpose per connected account of the same network', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $workspace = Workspace::factory()->create(); $first = SocialAccount::factory()->for($workspace)->create(); diff --git a/tests/Feature/Repurpose/SourceInvariantsTest.php b/tests/Feature/Repurpose/SourceInvariantsTest.php index 8558304a1..c37d90bb3 100644 --- a/tests/Feature/Repurpose/SourceInvariantsTest.php +++ b/tests/Feature/Repurpose/SourceInvariantsTest.php @@ -18,7 +18,6 @@ use Symfony\Component\HttpFoundation\Response; beforeEach(function () { - config()->set('trypost.allow_multiple_social_accounts', true); ['plain_token' => $token, 'workspace' => $this->workspace] = createApiTestToken(); diff --git a/tests/Feature/Repurpose/WebTest.php b/tests/Feature/Repurpose/WebTest.php index 05ca96203..9f6e490b4 100644 --- a/tests/Feature/Repurpose/WebTest.php +++ b/tests/Feature/Repurpose/WebTest.php @@ -439,7 +439,6 @@ function destinationPayload(SocialAccount $account): array }); test('the source account can be changed from the edit page', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $other = SocialAccount::factory()->for($this->workspace)->create(['platform' => Platform::Facebook]); diff --git a/tests/Feature/Services/Social/InstagramPublisherTest.php b/tests/Feature/Services/Social/InstagramPublisherTest.php index 8715b18b1..2a71ae499 100644 --- a/tests/Feature/Services/Social/InstagramPublisherTest.php +++ b/tests/Feature/Services/Social/InstagramPublisherTest.php @@ -1280,7 +1280,6 @@ function fakeJpegBytes(int $width = 1200, int $height = 800): string }); test('instagram facebook publisher recovers a published container on graph.facebook.com', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::InstagramFacebook, diff --git a/tests/Feature/Social/BlueskyControllerTest.php b/tests/Feature/Social/BlueskyControllerTest.php index 374050d27..51cf2e657 100644 --- a/tests/Feature/Social/BlueskyControllerTest.php +++ b/tests/Feature/Social/BlueskyControllerTest.php @@ -80,8 +80,7 @@ ]); }); -test('user can connect multiple bluesky accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple bluesky accounts', function () { SocialAccount::factory()->bluesky()->create([ 'workspace_id' => $this->workspace->id, @@ -113,40 +112,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(2); }); -test('bluesky store shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->bluesky()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'did:plc:existing123', - ]); - - Http::fake([ - 'https://bsky.social/xrpc/com.atproto.server.createSession' => Http::response([ - 'did' => 'did:plc:newuser456', - 'handle' => 'newuser.bsky.social', - 'accessJwt' => 'test-access-token', - 'refreshJwt' => 'test-refresh-token', - ], 200), - 'https://bsky.social/xrpc/app.bsky.actor.getProfile*' => Http::response([ - 'did' => 'did:plc:newuser456', - 'handle' => 'newuser.bsky.social', - 'displayName' => 'New User', - ], 200), - ]); - - $response = $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [ - 'identifier' => 'newuser.bsky.social', - 'password' => 'xxxx-xxxx-xxxx-xxxx', - ]); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Bluesky)->count())->toBe(1); -}); - test('bluesky connection validates required fields', function () { $response = $this->actingAs($this->user)->post(route('app.social.bluesky.store'), [ 'identifier' => '', diff --git a/tests/Feature/Social/DiscordControllerTest.php b/tests/Feature/Social/DiscordControllerTest.php index 58d04223c..5e45b94f3 100644 --- a/tests/Feature/Social/DiscordControllerTest.php +++ b/tests/Feature/Social/DiscordControllerTest.php @@ -91,8 +91,7 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(0); }); -test('user can connect multiple discord accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple discord accounts', function () { SocialAccount::factory()->discord()->create([ 'workspace_id' => $this->workspace->id, @@ -121,37 +120,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(2); }); -test('discord callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->discord()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => '999000111', - ]); - - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('888000222'); - $socialiteUser->shouldReceive('getNickname')->andReturn('Another Server'); - $socialiteUser->shouldReceive('getName')->andReturn('Another Server'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'discord-access-token'; - $socialiteUser->refreshToken = 'discord-refresh-token'; - $socialiteUser->expiresIn = null; - $socialiteUser->approvedScopes = ['bot', 'identify', 'guilds']; - - Socialite::shouldReceive('driver')->with('discord')->andReturn(Mockery::mock(['user' => $socialiteUser])); - - $response = $this->actingAs($this->user)->get(route('app.social.discord.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Discord)->count())->toBe(1); -}); - test('discord callback reconnects the original card', function () { $account = SocialAccount::factory()->discord()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Social/FacebookControllerTest.php b/tests/Feature/Social/FacebookControllerTest.php index d6507392e..d70cf5df7 100644 --- a/tests/Feature/Social/FacebookControllerTest.php +++ b/tests/Feature/Social/FacebookControllerTest.php @@ -2,7 +2,6 @@ declare(strict_types=1); -use App\Actions\Onboarding\ResolveOnboardingStatus; use App\Enums\SocialAccount\Platform; use App\Enums\SocialAccount\Status; use App\Enums\UserWorkspace\Role; @@ -100,55 +99,6 @@ ]); }); -test('facebook callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Facebook, - 'platform_user_id' => 'existing_page', - ]); - - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); - $socialiteUser->token = 'test-user-token'; - - Socialite::shouldReceive('driver') - ->with('facebook') - ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); - - $graphApi = config('trypost.platforms.facebook.graph_api'); - - Http::fake([ - "{$graphApi}/me/permissions*" => Http::response(['data' => [['permission' => 'pages_show_list', 'status' => 'granted']]], 200), - "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => []], 200), - "{$graphApi}/me/accounts*" => Http::response([ - 'data' => [ - [ - 'id' => 'page_123', - 'name' => 'My Facebook Page', - 'username' => 'myfbpage', - 'picture' => ['data' => ['url' => null]], - 'access_token' => 'page-access-token', - ], - ], - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.facebook.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - // The duplicate was blocked by the observer — only the pre-existing account remains. - expect($this->workspace->socialAccounts()->where('platform', Platform::Facebook)->count())->toBe(1); -}); - test('facebook callback redirects to page selection when multiple pages', function () { session([ 'social_connect_workspace' => $this->workspace->id, @@ -426,8 +376,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple facebook accounts when multiple social accounts are allowed', function () { - config(['trypost.allow_multiple_social_accounts' => true]); +test('user can connect multiple facebook accounts', function () { SocialAccount::factory()->facebook()->create([ 'workspace_id' => $this->workspace->id, @@ -524,7 +473,6 @@ $response->assertInertia(fn (AssertableInertia $page) => $page ->component('accounts/PopupCallback') ->where('success', true) - ->where('onboardingProgress', false) ); $this->assertDatabaseHas('social_accounts', [ @@ -534,8 +482,6 @@ 'username' => 'myfbpage', ]); - // After connect the session is cleared; PopupCallback sets onboardingProgress - // inline so Inertia does not deferred-reload this select URL into /accounts. $this->actingAs($this->user) ->get(route('app.social.facebook.select-page')) ->assertOk() @@ -543,13 +489,10 @@ ->component('accounts/PopupCallback') ->where('success', false) ->where('message', __('accounts.popup_callback.session_expired')) - ->where('onboardingProgress', false) ); }); test('facebook select page returns popup callback when the session expired', function () { - // Popup stays on PopupCallback — never dump /accounts. popupCallback() sets - // onboardingProgress inline so Inertia won't deferred-reload this URL. $this->actingAs($this->user) ->get(route('app.social.facebook.select-page')) ->assertOk() @@ -557,52 +500,6 @@ ->component('accounts/PopupCallback') ->where('success', false) ->where('message', __('accounts.popup_callback.session_expired')) - ->where('onboardingProgress', false) - ); -}); - -test('facebook popup callback overrides deferred onboarding progress for mid-activation owners', function () { - config(['trypost.self_hosted' => false]); - subscribeAccount($this->user->account); - - expect(app(ResolveOnboardingStatus::class)->canShowProgress($this->user->fresh()))->toBeTrue(); - - // Picker page may still defer onboarding — that is fine while the OAuth session exists. - session([ - 'social_connect_workspace' => $this->workspace->id, - 'facebook_oauth' => [ - 'user_token' => 'test-user-token', - 'user_id' => 'facebook_user_123', - 'pages' => [ - [ - 'id' => 'page_123', - 'name' => 'My Facebook Page', - 'username' => 'myfbpage', - 'picture' => null, - 'access_token' => 'page-access-token', - ], - ], - ], - ]); - - $this->actingAs($this->user->fresh()) - ->get(route('app.social.facebook.select-page')) - ->assertOk() - ->assertInertia(fn (AssertableInertia $page) => $page - ->component('accounts/FacebookPageSelect') - ->missing('onboardingProgress') - ->has('pages', 1) - ); - - // Close/error page must force inline false so Inertia does not re-GET select-page. - session()->forget(['facebook_oauth', 'social_connect_workspace']); - - $this->actingAs($this->user->fresh()) - ->get(route('app.social.facebook.select-page')) - ->assertOk() - ->assertInertia(fn (AssertableInertia $page) => $page - ->component('accounts/PopupCallback') - ->where('onboardingProgress', false) ); }); @@ -895,8 +792,7 @@ ); }); -test('facebook says every page is connected instead of network_taken in multi-account mode', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('facebook says every page is connected', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -1483,7 +1379,6 @@ }); test('facebook says the walk was cut short rather than claiming everything is connected', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -1525,45 +1420,3 @@ ->where('success', false) ->where('message', __('accounts.popup_callback.pages_read_incomplete'))); }); - -test('facebook still says the slot is taken when the walk came back short', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Facebook, - 'platform_user_id' => 'page_taken', - ]); - - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('facebook_user_123'); - $socialiteUser->token = 'test-user-token'; - - Socialite::shouldReceive('driver') - ->with('facebook') - ->andReturn(Mockery::mock()->shouldReceive('usingGraphVersion')->andReturnSelf()->shouldReceive('user')->andReturn($socialiteUser)->getMock()); - - $graphApi = config('trypost.platforms.facebook.graph_api'); - - Http::fake([ - "{$graphApi}/me?*" => Http::response(['id' => 'facebook_user_123', 'name' => 'User'], 200), - "{$graphApi}/me/permissions*" => Http::response(['data' => [ - ['permission' => 'business_management', 'status' => 'granted'], - ]], 200), - "{$graphApi}/me/accounts*" => Http::response(['data' => [[ - 'id' => 'page_taken', - 'name' => 'Already Connected', - 'picture' => ['data' => ['url' => null]], - 'access_token' => 'page-token', - ]]], 200), - "{$graphApi}/me/businesses*" => Http::response(['data' => [['id' => 'biz_1']]], 200), - "{$graphApi}/biz_1/owned_pages*" => Http::response(['error' => ['message' => 'busy', 'code' => 2]], 500), - "{$graphApi}/biz_1/client_pages*" => Http::response(['data' => []], 200), - ]); - - $this->actingAs($this->user) - ->get(route('app.social.facebook.callback')) - ->assertInertia(fn (AssertableInertia $page) => $page - ->where('success', false) - ->where('message', __('accounts.popup_callback.network_taken'))); -}); diff --git a/tests/Feature/Social/InstagramControllerTest.php b/tests/Feature/Social/InstagramControllerTest.php index 3efef9962..e20c5d552 100644 --- a/tests/Feature/Social/InstagramControllerTest.php +++ b/tests/Feature/Social/InstagramControllerTest.php @@ -94,8 +94,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple instagram accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple instagram accounts', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -132,43 +131,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(2); }); -test('instagram callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'existing-ig', - ]); - - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('12345678'); - $socialiteUser->shouldReceive('getNickname')->andReturn('testuser'); - $socialiteUser->shouldReceive('getName')->andReturn('Test User'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 5184000; - $socialiteUser->user = ['account_type' => 'BUSINESS']; - - Socialite::shouldReceive('driver') - ->with('instagram') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); - - $response = $this->actingAs($this->user)->get(route('app.social.instagram.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Instagram)->count())->toBe(1); -}); - test('instagram callback handles oauth errors gracefully', function () { session([ 'social_connect_workspace' => $this->workspace->id, @@ -197,7 +159,6 @@ }); test('instagram callback refuses an identity already connected via the facebook variant', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Social/InstagramFacebookControllerTest.php b/tests/Feature/Social/InstagramFacebookControllerTest.php index b17c04068..70d9b94f1 100644 --- a/tests/Feature/Social/InstagramFacebookControllerTest.php +++ b/tests/Feature/Social/InstagramFacebookControllerTest.php @@ -362,7 +362,7 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::InstagramFacebook)->count())->toBe(0); }); -test('instagram-facebook select skips deferred onboarding progress in self-hosted mode', function () { +test('instagram-facebook select expires the pending session after a successful pick', function () { config()->set('trypost.self_hosted', true); session([ @@ -393,7 +393,6 @@ $response->assertInertia(fn (AssertableInertia $page) => $page ->component('accounts/PopupCallback') ->where('success', true) - ->where('onboardingProgress', false) ); $this->assertDatabaseHas('social_accounts', [ @@ -403,8 +402,6 @@ 'username' => 'mybiz', ]); - // After connect the session is cleared; PopupCallback sets onboardingProgress - // inline so Inertia does not deferred-reload this select URL into /accounts. $this->actingAs($this->user) ->get(route('app.social.instagram-facebook.select-page')) ->assertOk() @@ -412,7 +409,6 @@ ->component('accounts/PopupCallback') ->where('success', false) ->where('message', __('accounts.popup_callback.session_expired')) - ->where('onboardingProgress', false) ); }); @@ -471,53 +467,10 @@ ->component('accounts/PopupCallback') ->where('success', false) ->where('message', __('accounts.popup_callback.session_expired')) - ->where('onboardingProgress', false) ); }); -test('instagram-facebook select shows network_taken when a standalone instagram is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'existing-ig', - ]); - - session([ - 'social_connect_workspace' => $this->workspace->id, - 'instagram_facebook_oauth' => [ - 'user_token' => 'user-token', - 'reconnect_id' => null, - 'pages' => [ - [ - 'page_id' => 'page-1', - 'page_name' => 'My Page', - 'page_access_token' => 'page-token', - 'ig_id' => 'ig-new', - 'ig_username' => 'mybiz', - 'ig_described' => true, - 'ig_name' => 'My Biz', - 'ig_picture' => null, - ], - ], - ], - ]); - - $response = $this->actingAs($this->user)->post(route('app.social.instagram-facebook.select'), [ - 'page_id' => 'page-1', - ]); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->whereIn('platform', [Platform::Instagram->value, Platform::InstagramFacebook->value])->count())->toBe(1); -}); - -test('instagram-facebook callback hides an instagram already connected standalone in multi-account mode', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('instagram-facebook callback hides an instagram already connected standalone', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -708,7 +661,6 @@ }); test('instagram via facebook does not describe a page it is about to discard', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Social/LinkedInControllerTest.php b/tests/Feature/Social/LinkedInControllerTest.php index 43c3e9f10..b7453023d 100644 --- a/tests/Feature/Social/LinkedInControllerTest.php +++ b/tests/Feature/Social/LinkedInControllerTest.php @@ -484,40 +484,7 @@ function captureLinkedInConnectScopes(object $test): array $response->assertInertia(fn (Assert $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('selecting the person shows network_taken when a linkedin page already occupies the network', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->linkedinPage()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'existing-linkedin-page', - ]); - - session(['linkedin_pending' => [ - 'workspace_id' => $this->workspace->id, - 'token' => 'test-access-token', - 'refresh_token' => 'test-refresh-token', - 'expires_in' => 5184000, - 'approved_scopes' => ['openid', 'profile', 'email', 'w_member_social'], - 'person' => ['id' => 'new-person', 'name' => 'John Doe', 'avatar' => null, 'vanity_name' => 'johndoe'], - 'organizations' => [], - ]]); - - $response = $this->actingAs($this->user)->post(route('app.social.linkedin.select'), [ - 'type' => 'person', - ]); - - $response->assertOk(); - $response->assertInertia(fn (Assert $page) => $page->where('success', false)); - $response->assertInertia(fn (Assert $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - $this->assertDatabaseMissing('social_accounts', [ - 'platform' => Platform::LinkedIn->value, - 'platform_user_id' => 'new-person', - ]); -}); - -test('user can connect multiple linkedin organizations when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple linkedin organizations', function () { SocialAccount::factory()->linkedinPage()->create([ 'workspace_id' => $this->workspace->id, @@ -693,7 +660,6 @@ function captureLinkedInConnectScopes(object $test): array }); test('select-identity hides an organization that is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -725,9 +691,7 @@ function captureLinkedInConnectScopes(object $test): array ); }); -test('select-identity reports the network is taken when nothing is connectable', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - +test('select-identity reports everything connected when nothing is connectable', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn, @@ -750,7 +714,7 @@ function captureLinkedInConnectScopes(object $test): array ->assertInertia(fn (Assert $page) => $page ->component('accounts/PopupCallback') ->where('success', false) - ->where('message', __('accounts.popup_callback.network_taken')) + ->where('message', __('accounts.popup_callback.all_connected')) ); expect(session()->has('linkedin_pending'))->toBeFalse(); @@ -780,7 +744,7 @@ function captureLinkedInConnectScopes(object $test): array ); }); -test('select-identity does not defer onboarding progress back onto its own route', function () { +test('select-identity does not leave a pending session after rendering', function () { config()->set('trypost.platforms.linkedin.enabled', false); config()->set('trypost.platforms.linkedin-page.enabled', true); @@ -799,7 +763,6 @@ function captureLinkedInConnectScopes(object $test): array ->assertOk() ->assertInertia(fn (Assert $page) => $page ->component('accounts/LinkedInSelect') - ->where('onboardingProgress', false) ); expect(session()->has('linkedin_pending'))->toBeFalse(); diff --git a/tests/Feature/Social/MastodonControllerTest.php b/tests/Feature/Social/MastodonControllerTest.php index d75f44032..768c3db9e 100644 --- a/tests/Feature/Social/MastodonControllerTest.php +++ b/tests/Feature/Social/MastodonControllerTest.php @@ -144,8 +144,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple mastodon accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple mastodon accounts', function () { SocialAccount::factory()->mastodon()->create([ 'workspace_id' => $this->workspace->id, @@ -184,47 +183,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(2); }); -test('mastodon callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->mastodon()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => '123456789', - ]); - - session([ - 'mastodon_instance' => 'https://mastodon.social', - 'mastodon_client_id' => 'test-client-id', - 'mastodon_client_secret' => 'test-client-secret', - 'mastodon_oauth_state' => 'test-state', - 'social_connect_workspace' => $this->workspace->id, - ]); - - Http::fake([ - 'https://mastodon.social/oauth/token' => Http::response([ - 'access_token' => 'new-access-token', - 'token_type' => 'Bearer', - ], 200), - 'https://mastodon.social/api/v1/accounts/verify_credentials' => Http::response([ - 'id' => '987654321', - 'username' => 'anotheruser', - 'acct' => 'anotheruser', - 'display_name' => 'Another User', - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.mastodon.callback', [ - 'code' => 'test-auth-code', - 'state' => 'test-state', - ])); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Mastodon)->count())->toBe(1); -}); - test('mastodon connection validates instance url', function () { $response = $this->actingAs($this->user)->post(route('app.social.mastodon.authorize'), [ 'instance' => 'not-a-valid-url', diff --git a/tests/Feature/Social/PinterestControllerTest.php b/tests/Feature/Social/PinterestControllerTest.php index d9d70f18d..955ad196e 100644 --- a/tests/Feature/Social/PinterestControllerTest.php +++ b/tests/Feature/Social/PinterestControllerTest.php @@ -122,8 +122,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple pinterest accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple pinterest accounts', function () { SocialAccount::factory()->pinterest()->create([ 'workspace_id' => $this->workspace->id, @@ -158,45 +157,7 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(2); }); -test('pinterest callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->pinterest()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'pinterest_user_123', - ]); - - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('pinterest_user_456'); - $socialiteUser->shouldReceive('getNickname')->andReturn('anotherpinner'); - $socialiteUser->shouldReceive('getName')->andReturn('Another Pinterest User'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'new-access-token'; - $socialiteUser->refreshToken = 'new-refresh-token'; - $socialiteUser->expiresIn = 2592000; - $socialiteUser->approvedScopes = ['boards:read', 'boards:write', 'pins:read', 'pins:write', 'user_accounts:read']; - - Socialite::shouldReceive('driver') - ->with('pinterest') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); - - $response = $this->actingAs($this->user)->get(route('app.social.pinterest.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Pinterest)->count())->toBe(1); -}); - test('pinterest callback reconnects the same identity via updateOrCreate', function () { - config()->set('trypost.allow_multiple_social_accounts', false); SocialAccount::factory()->pinterest()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Social/TelegramConnectionTest.php b/tests/Feature/Social/TelegramConnectionTest.php index 335cce894..fcdea7bee 100644 --- a/tests/Feature/Social/TelegramConnectionTest.php +++ b/tests/Feature/Social/TelegramConnectionTest.php @@ -121,39 +121,8 @@ function telegramUpdate(string $code, array $chat = []): array expect(data_get($account->meta, 'username'))->toBeNull(); }); -it('does not connect a second telegram channel when one is already connected', function () { - config([ - 'trypost.self_hosted' => false, - 'trypost.allow_multiple_social_accounts' => false, - ]); - Event::fake([TelegramConnectFailed::class]); - - SocialAccount::factory()->telegram()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => '-1009999999999', - ]); - - $code = TelegramConnectCode::issue($this->workspace->id, now()->addMinutes(15)); - - $this->withHeader('X-Telegram-Bot-Api-Secret-Token', 'shh-secret') - ->postJson(route('telegram.webhook'), telegramUpdate($code)) - ->assertNoContent(); - - expect($this->workspace->socialAccounts()->count())->toBe(1); - expect( - SocialAccount::where('platform', Platform::Telegram)->where('platform_user_id', '-1001234567890')->exists() - )->toBeFalse(); - - Event::assertDispatched( - TelegramConnectFailed::class, - fn (TelegramConnectFailed $event): bool => $event->workspaceId === $this->workspace->id - && $event->reason === 'network_taken', - ); -}); - -it('connects a second telegram channel when multiple social accounts are allowed', function () { +it('connects a second telegram channel alongside the first', function () { Http::fake(); - config(['trypost.allow_multiple_social_accounts' => true]); SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, @@ -174,7 +143,6 @@ function telegramUpdate(string $code, array $chat = []): array it('reconnects an existing telegram channel', function () { Http::fake(); - config(['trypost.allow_multiple_social_accounts' => false]); SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, @@ -209,7 +177,6 @@ function telegramUpdate(string $code, array $chat = []): array it('keeps the reconnect card on its own chat when a different channel posts the code', function () { Http::fake(); Event::fake([TelegramConnectFailed::class]); - config(['trypost.allow_multiple_social_accounts' => false]); $account = SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, @@ -234,7 +201,6 @@ function telegramUpdate(string $code, array $chat = []): array it('lets the user retry in the right chat with the same code', function () { Http::fake(); - config(['trypost.allow_multiple_social_accounts' => false]); $account = SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, @@ -258,7 +224,6 @@ function telegramUpdate(string $code, array $chat = []): array it('reconnects the card when its own chat posts the code', function () { Http::fake(); - config(['trypost.allow_multiple_social_accounts' => false]); $account = SocialAccount::factory()->telegram()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/Social/ThreadsControllerTest.php b/tests/Feature/Social/ThreadsControllerTest.php index eb286575c..d3c3d4da7 100644 --- a/tests/Feature/Social/ThreadsControllerTest.php +++ b/tests/Feature/Social/ThreadsControllerTest.php @@ -106,8 +106,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple threads accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple threads accounts', function () { SocialAccount::factory()->threads()->create([ 'workspace_id' => $this->workspace->id, @@ -173,51 +172,6 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.')); }); -test('threads callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->threads()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'existing-threads', - ]); - - $state = bin2hex(random_bytes(16)); - - session([ - 'social_connect_workspace' => $this->workspace->id, - 'threads_oauth_state' => $state, - ]); - - Http::fake([ - 'https://graph.threads.net/oauth/access_token' => Http::response([ - 'access_token' => 'short-lived-token', - 'user_id' => '123456789', - ], 200), - 'https://graph.threads.net/access_token*' => Http::response([ - 'access_token' => 'long-lived-token', - 'expires_in' => 5184000, - ], 200), - 'https://graph.threads.net/v1.0/123456789*' => Http::response([ - 'id' => '123456789', - 'username' => 'testuser', - 'name' => 'Test User', - 'threads_profile_picture_url' => null, - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.threads.callback', [ - 'code' => 'test-auth-code', - 'state' => $state, - ])); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::Threads)->count())->toBe(1); -}); - test('threads callback fails the connect when the long-lived token exchange fails', function () { $state = bin2hex(random_bytes(16)); diff --git a/tests/Feature/Social/TikTokControllerTest.php b/tests/Feature/Social/TikTokControllerTest.php index ecb6ca2fe..19b9c0265 100644 --- a/tests/Feature/Social/TikTokControllerTest.php +++ b/tests/Feature/Social/TikTokControllerTest.php @@ -95,8 +95,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple tiktok accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple tiktok accounts', function () { SocialAccount::factory()->tiktok()->create([ 'workspace_id' => $this->workspace->id, @@ -133,45 +132,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(2); }); -test('tiktok callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->tiktok()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'tiktok123', - ]); - - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('tiktok456'); - $socialiteUser->shouldReceive('getNickname')->andReturn('anothertiktoker'); - $socialiteUser->shouldReceive('getName')->andReturn('Another TikTok User'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'new-access-token'; - $socialiteUser->refreshToken = 'new-refresh-token'; - $socialiteUser->expiresIn = 86400; - $socialiteUser->approvedScopes = ['user.info.basic', 'user.info.profile', 'video.publish']; - - $socialiteMock = Mockery::mock(); - $socialiteMock->shouldReceive('scopes')->andReturn($socialiteMock); - $socialiteMock->shouldReceive('user')->andReturn($socialiteUser); - - Socialite::shouldReceive('driver') - ->with('tiktok') - ->andReturn($socialiteMock); - - $response = $this->actingAs($this->user)->get(route('app.social.tiktok.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::TikTok)->count())->toBe(1); -}); - test('tiktok callback handles oauth errors gracefully', function () { session([ 'social_connect_workspace' => $this->workspace->id, diff --git a/tests/Feature/Social/XControllerTest.php b/tests/Feature/Social/XControllerTest.php index 1c343e0f9..ebddb8267 100644 --- a/tests/Feature/Social/XControllerTest.php +++ b/tests/Feature/Social/XControllerTest.php @@ -94,8 +94,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple x accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple x accounts', function () { SocialAccount::factory()->x()->create([ 'workspace_id' => $this->workspace->id, @@ -130,42 +129,6 @@ expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(2); }); -test('x callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->x()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => '123456789', - ]); - - session(['social_connect_workspace' => $this->workspace->id]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('987654321'); - $socialiteUser->shouldReceive('getNickname')->andReturn('anotheruser'); - $socialiteUser->shouldReceive('getName')->andReturn('Another User'); - $socialiteUser->shouldReceive('getAvatar')->andReturn(null); - $socialiteUser->token = 'new-access-token'; - $socialiteUser->refreshToken = 'new-refresh-token'; - $socialiteUser->expiresIn = 7200; - $socialiteUser->approvedScopes = ['tweet.read', 'tweet.write']; - - Socialite::shouldReceive('driver') - ->with('x') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); - - $response = $this->actingAs($this->user)->get(route('app.social.x.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::X)->count())->toBe(1); -}); - test('x callback reconnects the original card', function () { $account = SocialAccount::factory()->x()->create([ 'workspace_id' => $this->workspace->id, @@ -225,7 +188,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.')); }); -test('x reconnect that authorizes another account says so instead of network_taken', function () { +test('x reconnect that authorizes another account says so', function () { $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::X, diff --git a/tests/Feature/Social/YouTubeControllerTest.php b/tests/Feature/Social/YouTubeControllerTest.php index cb9ac8000..7602550b6 100644 --- a/tests/Feature/Social/YouTubeControllerTest.php +++ b/tests/Feature/Social/YouTubeControllerTest.php @@ -210,8 +210,7 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Session expired. Please try again.')); }); -test('user can connect multiple youtube accounts when multiple social accounts are allowed', function () { - config()->set('trypost.allow_multiple_social_accounts', true); +test('user can connect multiple youtube accounts', function () { SocialAccount::factory()->youtube()->create([ 'workspace_id' => $this->workspace->id, @@ -277,56 +276,6 @@ $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', 'Error connecting account. Please try again.')); }); -test('youtube callback shows network_taken when the network is already connected', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->youtube()->create([ - 'workspace_id' => $this->workspace->id, - 'platform_user_id' => 'UC_existing', - ]); - - session([ - 'social_connect_workspace' => $this->workspace->id, - ]); - - $socialiteUser = Mockery::mock(SocialiteUser::class); - $socialiteUser->shouldReceive('getId')->andReturn('google_user_123'); - $socialiteUser->token = 'test-access-token'; - $socialiteUser->refreshToken = 'test-refresh-token'; - $socialiteUser->expiresIn = 3600; - - Socialite::shouldReceive('driver') - ->with('google') - ->andReturn(Mockery::mock([ - 'user' => $socialiteUser, - ])); - - Http::fake([ - 'https://www.googleapis.com/youtube/v3/channels*' => Http::response([ - 'items' => [ - [ - 'id' => 'UC_channel_123', - 'snippet' => [ - 'title' => 'My YouTube Channel', - 'customUrl' => '@mychannel', - 'thumbnails' => ['default' => ['url' => null]], - ], - 'statistics' => ['subscriberCount' => 1000], - ], - ], - ], 200), - ]); - - $response = $this->actingAs($this->user)->get(route('app.social.youtube.callback')); - - $response->assertOk(); - $response->assertInertia(fn (AssertableInertia $page) => $page->component('accounts/PopupCallback')); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('success', false)); - $response->assertInertia(fn (AssertableInertia $page) => $page->where('message', __('accounts.popup_callback.network_taken'))); - - expect($this->workspace->socialAccounts()->where('platform', Platform::YouTube)->count())->toBe(1); -}); - test('youtube reconnect keeps the original card for a single channel', function () { $account = SocialAccount::factory()->youtube()->create([ 'workspace_id' => $this->workspace->id, @@ -481,7 +430,6 @@ }); test('youtube skips an already connected channel and takes the next one', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->youtube()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/SocialAccount/MultipleAccountsConfigTest.php b/tests/Feature/SocialAccount/MultipleAccountsConfigTest.php deleted file mode 100644 index 85387ba01..000000000 --- a/tests/Feature/SocialAccount/MultipleAccountsConfigTest.php +++ /dev/null @@ -1,69 +0,0 @@ -original = collect($keys) - ->mapWithKeys(fn (string $key) => [$key => $_ENV[$key] ?? null]) - ->all(); -}); - -afterEach(function () use ($putEnv) { - foreach ($this->original as $key => $value) { - $putEnv($key, $value); - } -}); - -$loadConfig = fn (): array => require config_path('trypost.php'); - -test('it falls back to the self-hosted default when neither env is set', function () use ($putEnv, $loadConfig) { - $putEnv('ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', null); - $putEnv('SELF_HOSTED', null); - - $config = $loadConfig(); - - expect($config['self_hosted'])->toBeTrue() - ->and($config['allow_multiple_social_accounts'])->toBeTrue(); -}); - -test('it falls back to an explicit self-hosted value', function () use ($putEnv, $loadConfig) { - $putEnv('ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', null); - $putEnv('SELF_HOSTED', 'false'); - - expect($loadConfig()['allow_multiple_social_accounts'])->toBeFalse(); -}); - -test('its own env wins over the self-hosted fallback', function () use ($putEnv, $loadConfig) { - $putEnv('SELF_HOSTED', 'true'); - $putEnv('ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', 'false'); - - expect($loadConfig()['allow_multiple_social_accounts'])->toBeFalse(); - - $putEnv('SELF_HOSTED', 'false'); - $putEnv('ALLOW_MULTIPLE_SOCIAL_ACCOUNTS', 'true'); - - expect($loadConfig()['allow_multiple_social_accounts'])->toBeTrue(); -}); diff --git a/tests/Feature/SocialAccount/NetworkUniquenessTest.php b/tests/Feature/SocialAccount/NetworkUniquenessTest.php index 733a45851..0dce367fe 100644 --- a/tests/Feature/SocialAccount/NetworkUniquenessTest.php +++ b/tests/Feature/SocialAccount/NetworkUniquenessTest.php @@ -17,36 +17,41 @@ beforeEach(function () { config()->set('trypost.self_hosted', false); - config()->set('trypost.allow_multiple_social_accounts', false); $this->workspace = Workspace::factory()->create(); }); -test('blocks a second account of the same network', function () { +test('allows a second account of the same network', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram, 'platform_user_id' => 'ig-a', ]); - expect(fn () => SocialAccount::factory()->create([ + $second = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::Instagram, 'platform_user_id' => 'ig-b', - ]))->toThrow(NetworkAlreadyConnectedException::class); + ]); + + expect($second->exists)->toBeTrue() + ->and($this->workspace->socialAccounts()->count())->toBe(2); }); -test('collapses platform variants into one network', function () { +test('allows both variants of a network side by side', function () { SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedIn, 'platform_user_id' => 'li-profile', ]); - expect(fn () => SocialAccount::factory()->create([ + $page = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, 'platform' => Platform::LinkedInPage, 'platform_user_id' => 'li-page', - ]))->toThrow(NetworkAlreadyConnectedException::class); + ]); + + expect($page->exists)->toBeTrue() + ->and($this->workspace->socialAccounts()->count())->toBe(2); }); test('allows different networks in the same workspace', function () { @@ -100,87 +105,7 @@ ->and($this->workspace->socialAccounts()->first()->username)->toBe('new'); }); -test('allowing multiple social accounts bypasses the one-per-network rule', function () { - config()->set('trypost.allow_multiple_social_accounts', true); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-a', - ]); - - $second = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-b', - ]); - - expect($second->exists)->toBeTrue(); -}); - -test('multiple social accounts can be enabled without self-hosted mode', function () { - config()->set('trypost.self_hosted', false); - config()->set('trypost.allow_multiple_social_accounts', true); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-a', - ]); - - $second = SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-b', - ]); - - expect($second->exists)->toBeTrue(); -}); - -test('self-hosted still enforces one-per-network when multiple social accounts are disabled', function () { - config()->set('trypost.self_hosted', true); - config()->set('trypost.allow_multiple_social_accounts', false); - - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-a', - ]); - - expect(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-b', - ]))->toThrow(NetworkAlreadyConnectedException::class); -}); - -test('occupiesNetwork is true when the workspace already has that network', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'ig-a', - ]); - - expect(SocialAccount::occupiesNetwork((string) $this->workspace->id, Platform::Instagram))->toBeTrue() - ->and(SocialAccount::occupiesNetwork((string) $this->workspace->id, Platform::X))->toBeFalse(); -}); - -test('blocks a same-id account connected via a different network variant', function () { - SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::Instagram, - 'platform_user_id' => 'shared-ig-id', - ]); - - expect(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $this->workspace->id, - 'platform' => Platform::InstagramFacebook, - 'platform_user_id' => 'shared-ig-id', - ]))->toThrow(NetworkAlreadyConnectedException::class); -}); - test('the same workspace platform identity cannot be stored twice', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -196,7 +121,6 @@ }); test('connectIdentity refuses to repoint the reconnect target at another identity', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -219,7 +143,6 @@ }); test('connectIdentity keeps posts on the card when a stray identity is authorized', function () { - config()->set('trypost.allow_multiple_social_accounts', true); $account = SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -268,7 +191,6 @@ }); test('connectIdentity reconnect throws when the new identity is already taken', function () { - config()->set('trypost.allow_multiple_social_accounts', true); SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/SocialAccountModelTest.php b/tests/Feature/SocialAccountModelTest.php index f366a7cd9..9be609c75 100644 --- a/tests/Feature/SocialAccountModelTest.php +++ b/tests/Feature/SocialAccountModelTest.php @@ -24,7 +24,6 @@ // ---- needsProactiveTokenRefresh ---- test('needsProactiveTokenRefresh: rotating platforms refresh only when expired, extension platforms while still valid', function () { - config()->set('trypost.allow_multiple_social_accounts', true); // Rotating (X): a still-valid token that is merely expiring soon is NOT refreshed. $xValid = SocialAccount::factory()->x()->create([ 'workspace_id' => $this->workspace->id, diff --git a/tests/Feature/SocialControllerTest.php b/tests/Feature/SocialControllerTest.php index 824368f21..1c13e0eab 100644 --- a/tests/Feature/SocialControllerTest.php +++ b/tests/Feature/SocialControllerTest.php @@ -40,13 +40,10 @@ ->has('platforms') ->has('platforms.0.network') ->has('connectedAccounts', 1) - ->where('allowMultipleSocialAccounts', false) ); }); -test('accounts index still lists every same-network account when multiples are disabled', function () { - config()->set('trypost.allow_multiple_social_accounts', false); - +test('accounts index lists every account of the same network', function () { [$first, $second] = SocialAccount::withoutEvents(fn () => [ SocialAccount::factory()->create([ 'workspace_id' => $this->workspace->id, @@ -215,7 +212,7 @@ ); }); -test('an unsubscribed account can disconnect during onboarding (no active subscription required)', function () { +test('an unsubscribed account can disconnect without an active subscription', function () { config(['trypost.self_hosted' => false]); $account = SocialAccount::factory()->create(['workspace_id' => $this->workspace->id]); diff --git a/tests/Feature/UsageControllerTest.php b/tests/Feature/UsageControllerTest.php index cd4063d07..5ca565fa7 100644 --- a/tests/Feature/UsageControllerTest.php +++ b/tests/Feature/UsageControllerTest.php @@ -2,48 +2,8 @@ declare(strict_types=1); -use App\Enums\UserWorkspace\Role; -use App\Models\Account; -use App\Models\User; -use App\Models\Workspace; +use Illuminate\Support\Facades\Route; -beforeEach(function () { - $this->account = Account::factory()->create(); - $this->user = User::factory()->create([ - 'account_id' => $this->account->id, - ]); - $this->account->update(['owner_id' => $this->user->id]); - $this->workspace = Workspace::factory()->create([ - 'account_id' => $this->account->id, - 'user_id' => $this->user->id, - ]); - $this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]); - $this->user->update(['current_workspace_id' => $this->workspace->id]); -}); - -test('usage index redirects to calendar in self hosted mode', function () { - config(['trypost.self_hosted' => true]); - - $response = $this->actingAs($this->user)->get(route('app.usage.index')); - - $response->assertRedirect(route('app.calendar')); -}); - -test('usage index shows usage page when not self hosted', function () { - config(['trypost.self_hosted' => false]); - - $this->account->subscriptions()->create([ - 'type' => Account::SUBSCRIPTION_NAME, - 'stripe_id' => 'sub_test_'.fake()->uuid(), - 'stripe_status' => 'active', - 'stripe_price' => 'price_123', - ]); - - $response = $this->actingAs($this->user)->get(route('app.usage.index')); - - $response->assertOk(); - $response->assertInertia(fn ($page) => $page - ->component('settings/account/Usage', false) - ->has('usage') - ); +test('the usage settings route is no longer registered', function () { + expect(Route::has('app.usage.index'))->toBeFalse(); }); diff --git a/tests/Feature/Welcome/PlanSelectionTest.php b/tests/Feature/Welcome/PlanSelectionTest.php new file mode 100644 index 000000000..dae4437a5 --- /dev/null +++ b/tests/Feature/Welcome/PlanSelectionTest.php @@ -0,0 +1,132 @@ + false]); + + $this->user = User::factory()->create([ + 'persona' => Persona::Creator, + 'goals' => [Goal::SaveTime->value], + 'referral_source' => ReferralSource::Google, + ]); + + $workspace = Workspace::factory()->create([ + 'account_id' => $this->user->account_id, + 'user_id' => $this->user->id, + ]); + $workspace->members()->attach($this->user->id, ['role' => Role::Admin->value]); + $this->user->update(['current_workspace_id' => $workspace->id]); + + SocialAccount::factory()->create([ + 'workspace_id' => $workspace->id, + 'platform' => SocialPlatform::X, + 'status' => Status::Connected, + ]); +}); + +test('connect store sends the user to the plan step instead of Stripe', function () { + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.connect.store')) + ->assertRedirect(route('app.welcome.plan')); +}); + +test('the first-month offer copy mirrors the per-month suffix', function () { + expect(__('billing.plans.per_first_month')) + ->toBe('/first month') + ->and(__('billing.plans.per_month'))->toBe('/month') + ->and(__('billing.plans.then_monthly', ['price' => '$19']))->toBe('Then $19/month') + ->and(__('billing.subscribe.prices.first_month'))->toBe('$1'); +}); + +test('the plan step lists only active plans, ordered by sort', function () { + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.plan')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('welcome/Plan', false) + ->has('plans', 2) + ->where('plans.0.slug', Slug::Socials->value) + ->where('plans.0.workspace_limit', 1) + ->where('plans.1.slug', Slug::Workspaces->value) + ->where('plans.1.workspace_limit', null) + ); +}); + +test('the welcome plan step stores a locale change and stays on the page', function () { + $this->actingAs($this->user->fresh()) + ->from(route('app.welcome.plan')) + ->put(route('app.profile.language'), ['locale' => 'pt-BR']) + ->assertRedirect(route('app.welcome.plan')) + ->assertCookieMissing('locale'); + + expect($this->user->fresh()->locale)->toBe(Locale::PortugueseBrazil); +}); + +test('the plan step redirects back to connect when no account is connected', function () { + $this->user->fresh()->currentWorkspace->socialAccounts()->delete(); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.plan')) + ->assertRedirect(route('app.welcome.connect')); +}); + +test('the plan step rejects an archived plan', function () { + $legacy = Plan::where('slug', Slug::Workspace)->firstOrFail(); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.plan.store'), [ + 'plan_id' => $legacy->id, + 'interval' => 'monthly', + ]) + ->assertSessionHasErrors('plan_id'); +}); + +test('the plan step always checks out the monthly price of the chosen plan', function () { + $plan = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $plan->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); + + $this->mock(StartSubscriptionCheckout::class) + ->shouldReceive('redirect') + ->once() + ->withArgs(fn ($account, $priceId, $cancelUrl, $passedPlan) => $priceId === 'price_workspaces_monthly' && $passedPlan->is($plan)) + ->andReturn(redirect('https://checkout.stripe.test/session')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.plan.store'), [ + 'plan_id' => $plan->id, + 'interval' => 'yearly', + ]) + ->assertRedirect('https://checkout.stripe.test/session'); + + expect($this->user->account->fresh()->plan_id)->toBeNull(); +}); + +test('the plan step fails loudly when the price is not configured', function () { + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); + $plan->update(['stripe_monthly_price_id' => null]); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.plan.store'), [ + 'plan_id' => $plan->id, + 'interval' => 'monthly', + ]) + ->assertStatus(500); +}); diff --git a/tests/Feature/Welcome/WelcomeControllerTest.php b/tests/Feature/Welcome/WelcomeControllerTest.php index 61fa27ae8..a48df278d 100644 --- a/tests/Feature/Welcome/WelcomeControllerTest.php +++ b/tests/Feature/Welcome/WelcomeControllerTest.php @@ -221,7 +221,8 @@ $workspace = attachCurrentWorkspace($this->user); SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); + $plan->update([ 'stripe_monthly_price_id' => 'price_monthly_test', ]); @@ -246,6 +247,13 @@ $this->actingAs($this->user->fresh()) ->post(route('app.welcome.connect.store')) + ->assertRedirect(route('app.welcome.plan')); + + $this->actingAs($this->user->fresh()) + ->post(route('app.welcome.plan.store'), [ + 'plan_id' => $plan->id, + 'interval' => 'monthly', + ]) ->assertRedirect('https://checkout.stripe.test/session'); $funnel = WelcomeEvent::funnel(); @@ -393,6 +401,50 @@ ); }); +test('every step shares the welcome summary that fills in as the user progresses', function () { + $this->actingAs($this->user) + ->get(route('app.welcome.persona')) + ->assertInertia(fn ($page) => $page + ->component('welcome/Persona', false) + ->where('welcome.persona', null) + ->where('welcome.goals', []) + ->where('welcome.networks', []) + ); + + completeWelcomeThroughReferral($this->user); + $workspace = attachCurrentWorkspace($this->user); + $connected = SocialAccount::factory()->linkedin()->create([ + 'workspace_id' => $workspace->id, + 'avatar_url' => 'avatars/linkedin.jpg', + ]); + SocialAccount::factory()->x()->tokenExpired()->create(['workspace_id' => $workspace->id]); + + $this->actingAs($this->user->fresh()) + ->get(route('app.welcome.connect')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('welcome/Connect', false) + ->where('welcome.persona', $this->user->fresh()->persona->value) + ->where('welcome.goals', $this->user->fresh()->goals) + ->has('welcome.networks', 1) + ->where('welcome.networks.0.id', $connected->id) + ->where('welcome.networks.0.platform', SocialPlatform::LinkedIn->value) + ->where('welcome.networks.0.display_label', $connected->display_label) + ->where('welcome.networks.0.username', $connected->username) + ->where('welcome.networks.0.avatar_url', $connected->avatar_url) + ); +}); + +test('the welcome summary drops goals that no longer exist', function () { + $this->user->update(['goals' => [Goal::SaveTime->value, 'retired_goal']]); + + $this->actingAs($this->user) + ->get(route('app.welcome.persona')) + ->assertInertia(fn ($page) => $page + ->where('welcome.goals', [Goal::SaveTime->value]) + ); +}); + test('connect copy exists in every locale', function (string $locale) { expect(__('welcome.connect.title', [], $locale))->not->toBe('welcome.connect.title') ->and(__('welcome.connect.description', [], $locale))->not->toBe('welcome.connect.description') @@ -451,109 +503,41 @@ ->assertSessionHasErrors('connect'); }); -test('connect store starts Stripe checkout when a social account is connected', function () { +test('connect store advances to the plan step when a social account is connected', function () { config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); Bus::fake(); completeWelcomeThroughReferral($this->user); $workspace = attachCurrentWorkspace($this->user); SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ - 'stripe_monthly_price_id' => 'price_monthly_test', - ]); - - $this->mock(StartSubscriptionCheckout::class) - ->shouldReceive('redirect') - ->once() - ->withArgs(fn (Account $account, string $priceId, string $cancelUrl): bool => $account->is($this->user->account) - && $priceId === 'price_monthly_test' - && $cancelUrl === route('app.welcome.connect')) - ->andReturn(redirect('https://checkout.stripe.test/session')); + $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); $this->actingAs($this->user->fresh()) ->post(route('app.welcome.connect.store')) - ->assertRedirect('https://checkout.stripe.test/session'); + ->assertRedirect(route('app.welcome.plan')); Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' && data_get($event->payload, 'event') === WelcomeEvent::Connect->value && data_get($event->payload, 'properties.platforms') === [SocialPlatform::LinkedIn->value]); -}); - -test('connect store captures checkout.started with the plan name and interval', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Bus::fake(); - completeWelcomeThroughReferral($this->user); - $workspace = attachCurrentWorkspace($this->user); - SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - - $plan = Plan::where('slug', Slug::Workspace)->firstOrFail(); - $plan->update(['stripe_monthly_price_id' => 'price_monthly_test']); - - $this->mock(StartSubscriptionCheckout::class) - ->shouldReceive('redirect') - ->once() - ->andReturn(redirect('https://checkout.stripe.test/session')); - - $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.connect.store')); - - Bus::assertDispatched(SendEvent::class, fn (SendEvent $event): bool => $event->method === 'capture' - && data_get($event->payload, 'event') === CheckoutEvent::Started->value - && data_get($event->payload, 'properties.plan_name') === $plan->name - && data_get($event->payload, 'properties.interval') === 'monthly'); -}); - -test('connect store does not capture checkout.started when Stripe checkout creation fails', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Bus::fake(); - completeWelcomeThroughReferral($this->user); - $workspace = attachCurrentWorkspace($this->user); - SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - - Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ - 'stripe_monthly_price_id' => 'price_monthly_test', - ]); - - $this->mock(StartSubscriptionCheckout::class) - ->shouldReceive('redirect') - ->once() - ->andThrow(new RuntimeException('Stripe checkout could not be created.')); - - $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.connect.store')); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value, - ); Bus::assertNotDispatched( SendEvent::class, fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value, ); }); -test('connect store still redirects to stripe when posthog capture fails', function () { +test('connect store still advances to the plan step when posthog capture fails', function () { Exceptions::fake(); completeWelcomeThroughReferral($this->user); $workspace = attachCurrentWorkspace($this->user); SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - Plan::where('slug', Slug::Workspace)->firstOrFail()->update([ - 'stripe_monthly_price_id' => 'price_monthly_test', - ]); - - $this->mock(StartSubscriptionCheckout::class) - ->shouldReceive('redirect') - ->once() - ->andReturn(redirect('https://checkout.stripe.test/session')); - $this->mock(PostHogService::class) ->shouldReceive('capture') ->andThrow(new RuntimeException('PostHog is down.')); $this->actingAs($this->user->fresh()) ->post(route('app.welcome.connect.store')) - ->assertRedirect('https://checkout.stripe.test/session'); + ->assertRedirect(route('app.welcome.plan')); Exceptions::assertReported(RuntimeException::class); }); @@ -561,6 +545,10 @@ test('welcome steps redirect to calendar for subscribed accounts', function (string $routeName, string $method, array $payload = []) { subscribeAccount($this->user->account); + if ($routeName === 'app.welcome.plan.store') { + $payload['plan_id'] = Plan::where('slug', Slug::Socials)->value('id'); + } + $this->actingAs($this->user->fresh()); $response = $method === 'get' @@ -577,6 +565,8 @@ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], 'connect' => ['app.welcome.connect', 'get'], 'connect store' => ['app.welcome.connect.store', 'post'], + 'plan' => ['app.welcome.plan', 'get'], + 'plan store' => ['app.welcome.plan.store', 'post', ['interval' => 'monthly']], ]); test('welcome redirects generic-trial accounts with app access to calendar', function () { @@ -597,6 +587,10 @@ test('welcome steps redirect to calendar in self hosted mode', function (string $routeName, string $method, array $payload = []) { config(['trypost.self_hosted' => true]); + if ($routeName === 'app.welcome.plan.store') { + $payload['plan_id'] = Plan::where('slug', Slug::Socials)->value('id'); + } + $this->actingAs($this->user); $response = $method === 'get' @@ -613,13 +607,17 @@ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], 'connect' => ['app.welcome.connect', 'get'], 'connect store' => ['app.welcome.connect.store', 'post'], + 'plan' => ['app.welcome.plan', 'get'], + 'plan store' => ['app.welcome.plan.store', 'post', ['interval' => 'monthly']], ]); -test('old onboarding icp routes are not registered', function (string $routeName) { +test('old onboarding routes are not registered', function (string $routeName) { expect(Route::has($routeName))->toBeFalse(); })->with([ - // `app.onboarding` is reused for the post-subscription activation checklist. + 'index' => 'app.onboarding', 'store' => 'app.onboarding.store', + 'skip mcp' => 'app.onboarding.mcp.skip', + 'complete' => 'app.onboarding.complete', 'goals' => 'app.onboarding.goals', 'goals store' => 'app.onboarding.goals.store', 'referral source' => 'app.onboarding.referral-source', @@ -665,6 +663,10 @@ test('members without app access are held on the subscription required screen', function (string $routeName, string $method, array $payload = []) { $member = User::factory()->create(['account_id' => $this->user->account_id]); + if ($routeName === 'app.welcome.plan.store') { + $payload['plan_id'] = Plan::where('slug', Slug::Socials)->value('id'); + } + $this->actingAs($member->fresh()); $response = $method === 'get' @@ -681,6 +683,8 @@ 'referral source store' => ['app.welcome.referral-source.store', 'post', ['referral_source' => ReferralSource::Google->value]], 'connect' => ['app.welcome.connect', 'get'], 'connect store' => ['app.welcome.connect.store', 'post'], + 'plan' => ['app.welcome.plan', 'get'], + 'plan store' => ['app.welcome.plan.store', 'post', ['interval' => 'monthly']], ]); test('subscription required screen renders for members without app access', function () { @@ -737,30 +741,6 @@ ->assertRedirect(route('app.calendar')); }); -test('connect store fails loudly when the monthly price is not configured', function () { - config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test']); - Bus::fake(); - completeWelcomeThroughReferral($this->user); - $workspace = attachCurrentWorkspace($this->user); - SocialAccount::factory()->linkedin()->create(['workspace_id' => $workspace->id]); - Plan::where('slug', Slug::Workspace)->update(['stripe_monthly_price_id' => null]); - - $this->mock(StartSubscriptionCheckout::class)->shouldNotReceive('redirect'); - - $this->actingAs($this->user->fresh()) - ->post(route('app.welcome.connect.store')) - ->assertServerError(); - - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === WelcomeEvent::Connect->value, - ); - Bus::assertNotDispatched( - SendEvent::class, - fn (SendEvent $event): bool => data_get($event->payload, 'event') === CheckoutEvent::Started->value, - ); -}); - function completeWelcomeThroughReferral(User $user): void { $user->update([ diff --git a/tests/Feature/Workspace/WorkspaceLimitTest.php b/tests/Feature/Workspace/WorkspaceLimitTest.php new file mode 100644 index 000000000..1d46876c8 --- /dev/null +++ b/tests/Feature/Workspace/WorkspaceLimitTest.php @@ -0,0 +1,158 @@ + false]); +}); + +/** + * User::factory() does not create a workspace — only CreateUser does. + * Insert one so Socials / legacy start already at their cap of 1. + */ +$onPlan = function (Slug $slug): User { + $user = User::factory()->create(); + $user->account->update(['plan_id' => Plan::where('slug', $slug)->value('id')]); + + Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + // The controller guard checks hasActiveSubscription() *before* the cap. + // Without this, GET at the Socials cap still renders, but store flashes + // subscription_required instead of limit_reached. + subscribeAccount($user->account); + + return $user->fresh(); +}; + +test('an account with no plan can create its first workspace', function () { + $user = User::factory()->create(); + $user->account->update(['plan_id' => null]); + + expect($user->account->workspaces()->count())->toBe(0) + ->and($user->fresh()->account->workspaceLimit())->toBe(1) + ->and($user->fresh()->account->canCreateWorkspace())->toBeTrue(); +}); + +test('an account with no plan cannot create a second workspace', function () { + $user = User::factory()->create(); + $user->account->update(['plan_id' => null]); + + Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + expect($user->fresh()->account->workspaceLimit())->toBe(1) + ->and($user->fresh()->account->canCreateWorkspace())->toBeFalse(); +}); + +test('a subscribed account without a plan is still capped at one workspace', function () { + $user = User::factory()->create(); + $user->account->update(['plan_id' => null]); + + Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + subscribeAccount($user->account); + + $this->actingAs($user->fresh()) + ->get(route('app.workspaces.create')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('workspaces/Create', false) + ->where('features.workspaceLimit', 1) + ); +}); + +test('the socials plan blocks a second workspace', function () use ($onPlan) { + $user = $onPlan(Slug::Socials); + + expect($user->account->workspaces()->count())->toBe(1) + ->and($user->account->canCreateWorkspace())->toBeFalse(); +}); + +test('the workspaces plan allows more workspaces', function () use ($onPlan) { + $user = $onPlan(Slug::Workspaces); + + Workspace::factory()->count(3)->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + expect($user->account->canCreateWorkspace())->toBeTrue(); +}); + +test('the legacy plan is capped at one workspace', function () use ($onPlan) { + $user = $onPlan(Slug::Workspace); + + expect($user->account->canCreateWorkspace())->toBeFalse(); +}); + +test('self-hosted ignores the cap', function () use ($onPlan) { + $user = $onPlan(Slug::Socials); + + config(['trypost.self_hosted' => true]); + + expect($user->account->canCreateWorkspace())->toBeTrue(); +}); + +test('the create form is available when the plan is at its cap', function () use ($onPlan) { + $user = $onPlan(Slug::Socials); + + $this->actingAs($user) + ->get(route('app.workspaces.create')) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('workspaces/Create', false) + ); +}); + +test('a subscribed account without a plan cannot store a second workspace', function () { + $user = User::factory()->create(); + $user->account->update(['plan_id' => null]); + + Workspace::factory()->create([ + 'account_id' => $user->account_id, + 'user_id' => $user->id, + ]); + + subscribeAccount($user->account); + + $this->actingAs($user->fresh()) + ->post(route('app.workspaces.store'), ['name' => 'Second']) + ->assertRedirect(route('app.workspaces.create')) + ->assertSessionHas('flash.error', __('workspaces.limit_reached')); + + expect($user->account->workspaces()->count())->toBe(1); +}); + +test('a direct store POST cannot exceed the cap', function () use ($onPlan) { + $user = $onPlan(Slug::Socials); + + $this->actingAs($user) + ->post(route('app.workspaces.store'), ['name' => 'Second']) + ->assertRedirect(route('app.workspaces.create')) + ->assertSessionHas('flash.error', __('workspaces.limit_reached')); + + expect($user->account->workspaces()->count())->toBe(1); +}); + +test('the workspaces plan can store another workspace', function () use ($onPlan) { + $user = $onPlan(Slug::Workspaces); + + $this->actingAs($user) + ->post(route('app.workspaces.store'), ['name' => 'Second']) + ->assertRedirect(route('app.accounts')); + + expect($user->account->workspaces()->count())->toBe(2); +}); diff --git a/tests/Feature/WorkspaceBillingTest.php b/tests/Feature/WorkspaceBillingTest.php index 395e0f432..db1f58827 100644 --- a/tests/Feature/WorkspaceBillingTest.php +++ b/tests/Feature/WorkspaceBillingTest.php @@ -124,6 +124,10 @@ test('creating an additional workspace is allowed with an active subscription', function () { config(['trypost.self_hosted' => false]); + $this->account->update([ + 'plan_id' => Plan::where('slug', PlanSlug::Workspaces)->value('id'), + ]); + $this->account->subscriptions()->create([ 'type' => Account::SUBSCRIPTION_NAME, 'stripe_id' => 'sub_test_'.fake()->uuid(), diff --git a/tests/Pest.php b/tests/Pest.php index 9b4acb440..de5421840 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,6 +2,7 @@ declare(strict_types=1); +use App\Enums\Plan\Slug; use App\Enums\UserWorkspace\Role; use App\Models\AccessToken; use App\Models\Account; @@ -134,7 +135,7 @@ function createApiTestToken(array $overrides = []): array */ function billingAccount(string $price, array $subscriptionAttributes = [], int $workspaces = 1): Account { - $plan = Plan::query()->firstOrFail(); + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); $plan->update([ 'stripe_monthly_price_id' => 'price_month', 'stripe_yearly_price_id' => 'price_year', diff --git a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php index f8d37e8bf..bb1f3403f 100644 --- a/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php +++ b/tests/Unit/Actions/Billing/StartSubscriptionCheckoutTest.php @@ -3,9 +3,11 @@ declare(strict_types=1); use App\Actions\Billing\StartSubscriptionCheckout; +use App\Enums\Plan\Slug; use App\Enums\User\Persona; use App\Enums\User\ReferralSource; use App\Models\Account; +use App\Models\Plan; use App\Models\User; use App\Models\Workspace; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -19,7 +21,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -47,7 +52,6 @@ $cancelUrl = route('app.welcome'); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->with(1)->andReturnSelf(); $builder->shouldReceive('withMetadata') ->once() ->with([ @@ -100,7 +104,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -112,7 +119,6 @@ $cancelUrl = route('app.welcome'); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->andReturnSelf(); $builder->shouldReceive('withMetadata')->once()->with([])->andReturnSelf(); $builder->shouldReceive('trialDays')->once()->andReturnSelf(); $builder->shouldReceive('checkout') @@ -131,7 +137,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); @@ -144,7 +153,6 @@ $account->refresh(); $builder = Mockery::mock(SubscriptionBuilder::class); - $builder->shouldReceive('quantity')->once()->andReturnSelf(); $builder->shouldReceive('withMetadata') ->once() ->with(['fbclid' => str_repeat('a', 500)]) @@ -161,3 +169,114 @@ app(StartSubscriptionCheckout::class)->redirect($accountMock, 'price_monthly_test', route('app.welcome')); }); + +test('redirect applies the plan first-month coupon on a monthly price', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.allow_promotion_codes' => false, + ]); + + $plan = Plan::where('slug', Slug::Socials)->firstOrFail(); + $plan->update(['stripe_monthly_price_id' => 'price_socials_monthly']); + + $account = Account::factory()->create(); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('withMetadata')->once()->andReturnSelf(); + $builder->shouldReceive('withCoupon')->once()->with('SOCIALS_18USD')->andReturnSelf(); + $builder->shouldReceive('trialDays')->never(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect( + $accountMock, + 'price_socials_monthly', + route('app.welcome.plan'), + $plan, + ); +}); + +test('redirect skips the first-month coupon on a yearly price', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + 'cashier.allow_promotion_codes' => false, + ]); + + $plan = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $plan->update([ + 'stripe_monthly_price_id' => 'price_workspaces_monthly', + 'stripe_yearly_price_id' => 'price_workspaces_yearly', + ]); + + $account = Account::factory()->create(); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('withMetadata')->once()->andReturnSelf(); + $builder->shouldReceive('withCoupon')->never(); + $builder->shouldReceive('trialDays')->once()->with(8)->andReturnSelf(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect( + $accountMock, + 'price_workspaces_yearly', + route('app.welcome.plan'), + $plan, + ); +}); + +test('redirect clears a leftover plan_id before opening checkout', function () { + config([ + 'trypost.billing.require_card_for_trial' => true, + 'cashier.trial_days' => 8, + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], + 'cashier.allow_promotion_codes' => false, + ]); + + $workspaces = Plan::where('slug', Slug::Workspaces)->firstOrFail(); + $account = Account::factory()->create(['plan_id' => $workspaces->id]); + Workspace::factory()->create(['account_id' => $account->id]); + User::factory()->create(['account_id' => $account->id]); + $account->refresh(); + + $builder = Mockery::mock(SubscriptionBuilder::class); + $builder->shouldReceive('withMetadata')->once()->andReturnSelf(); + $builder->shouldReceive('trialDays')->once()->andReturnSelf(); + $builder->shouldReceive('checkout') + ->once() + ->andReturn((object) ['url' => 'https://checkout.stripe.test/session']); + + /** @var Account&MockInterface $accountMock */ + $accountMock = Mockery::mock($account)->makePartial(); + $accountMock->shouldReceive('createOrGetStripeCustomer')->once()->andReturnNull(); + $accountMock->shouldReceive('newSubscription')->once()->andReturn($builder); + + app(StartSubscriptionCheckout::class)->redirect($accountMock, 'price_monthly_test', route('app.welcome.plan')); + + expect($account->fresh()->plan_id)->toBeNull(); +}); diff --git a/tests/Unit/Events/OnboardingStatusUpdatedTest.php b/tests/Unit/Events/OnboardingStatusUpdatedTest.php deleted file mode 100644 index ec0a94117..000000000 --- a/tests/Unit/Events/OnboardingStatusUpdatedTest.php +++ /dev/null @@ -1,259 +0,0 @@ -uuid(); - $event = new OnboardingStatusUpdated($workspaceId); - $channels = $event->broadcastOn(); - - expect($channels)->toHaveCount(1) - ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) - ->and($channels[0]->name)->toBe("private-workspace.{$workspaceId}"); -}); - -test('event broadcasts with the workspace id', function () { - $workspaceId = fake()->uuid(); - $event = new OnboardingStatusUpdated($workspaceId); - - expect($event->broadcastWith())->toBe(['workspace_id' => $workspaceId]); -}); - -test('event broadcasts as a stable name', function () { - $event = new OnboardingStatusUpdated(fake()->uuid()); - - expect($event->broadcastAs())->toBe('onboarding.status.updated'); -}); - -test('event waits for the database transaction to commit', function () { - expect(new OnboardingStatusUpdated(fake()->uuid())) - ->toBeInstanceOf(ShouldDispatchAfterCommit::class); -}); - -test('dispatchForWorkspace skips blank workspace ids', function () { - Event::fake([OnboardingStatusUpdated::class]); - - OnboardingStatusUpdated::dispatchForWorkspace(null); - OnboardingStatusUpdated::dispatchForWorkspace(''); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('broadcastForAccount notifies every workspace even after completion', function () { - Event::fake([OnboardingStatusUpdated::class]); - - $user = User::factory()->create(); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - OnboardingStatusUpdated::broadcastForAccount($user->account->fresh()); - - Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 2); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id, - ); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id, - ); -}); - -test('dispatchForWorkspace fans out Echo to sibling workspaces while onboarding is open', function () { - Event::fake([OnboardingStatusUpdated::class]); - config(['trypost.self_hosted' => false]); - - $user = User::factory()->create(); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspaceA->id]); - subscribeAccount($user->account); - - OnboardingStatusUpdated::dispatchForWorkspace($workspaceA->id, $user->fresh()); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id, - ); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id, - ); -}); - -test('dispatchForWorkspace does not stamp when the surrounding transaction rolls back', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - config(['trypost.self_hosted' => false]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - subscribeAccount($user->account); - - AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ])); - - try { - DB::transaction(function () use ($user, $workspace): void { - OnboardingStatusUpdated::dispatchForWorkspace($workspace->id, $user->fresh()); - - throw new RuntimeException('force rollback'); - }); - } catch (RuntimeException) { - // Expected — sync/analytics must stay inside afterCommit. - } - - expect($user->account->fresh()->onboarding_completed_at)->toBeNull(); -}); - -test('dispatchForWorkspace stamps completion when actor current workspace differs', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - config(['trypost.self_hosted' => false]); - - $user = User::factory()->create(); - $readyWorkspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $otherWorkspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - // Persisted current workspace is elsewhere (API/MCP in-memory mutation case). - $user->update(['current_workspace_id' => $otherWorkspace->id]); - subscribeAccount($user->account); - - AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $readyWorkspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - 'user_id' => $user->id, - ])); - - // Align the request user to the ready workspace in memory only. - $actor = $user->fresh(); - $actor->setRelation('currentWorkspace', $readyWorkspace); - $actor->current_workspace_id = $readyWorkspace->id; - - OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id, $actor); - - expect($user->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('dispatchForWorkspace stamps when nobody is currently on the workspace', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - config(['trypost.self_hosted' => false]); - - $owner = User::factory()->create(['current_workspace_id' => null]); - $readyWorkspace = Workspace::factory()->create([ - 'account_id' => $owner->account_id, - 'user_id' => $owner->id, - ]); - subscribeAccount($owner->account); - - $owner->account->forceFill(['onboarding_skipped_steps' => ['mcp']])->save(); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - 'user_id' => $owner->id, - ])); - - $actor = $owner->fresh(); - $actor->setRelation('currentWorkspace', $readyWorkspace); - $actor->current_workspace_id = $readyWorkspace->id; - - OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id, $actor); - - expect($owner->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('dispatchForWorkspace stamps completion via the account owner when the actor is null', function () { - Carbon::setTestNow('2026-07-29 12:00:00'); - config(['trypost.self_hosted' => false]); - - $owner = User::factory()->create(['current_workspace_id' => null]); - $readyWorkspace = Workspace::factory()->create([ - 'account_id' => $owner->account_id, - 'user_id' => $owner->id, - ]); - subscribeAccount($owner->account); - - $owner->account->forceFill(['onboarding_skipped_steps' => ['mcp']])->save(); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $readyWorkspace->id, - 'user_id' => $owner->id, - ])); - - // Webhook-style dispatch (e.g. Telegram connect): no authenticated actor. - OnboardingStatusUpdated::dispatchForWorkspace($readyWorkspace->id); - - expect($owner->account->fresh()->onboarding_completed_at?->equalTo(now()))->toBeTrue(); -}); - -test('completion broadcasts exactly once per workspace', function () { - Event::fake([OnboardingStatusUpdated::class]); - config(['trypost.self_hosted' => false]); - - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - subscribeAccount($user->account); - - AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $workspace)); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - ])); - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ])); - - OnboardingStatusUpdated::dispatchForWorkspace($workspace->id, $user->fresh()); - - Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 1); -}); diff --git a/tests/Unit/Models/Traits/HasOnboardingTraitTest.php b/tests/Unit/Models/Traits/HasOnboardingTraitTest.php deleted file mode 100644 index c69cbef7b..000000000 --- a/tests/Unit/Models/Traits/HasOnboardingTraitTest.php +++ /dev/null @@ -1,104 +0,0 @@ -create([ - 'onboarding_completed_at' => null, - 'onboarding_dismissed_at' => null, - ]); - $completed = Account::factory()->create([ - 'onboarding_completed_at' => now(), - 'onboarding_dismissed_at' => null, - ]); - $dismissed = Account::factory()->create([ - 'onboarding_completed_at' => null, - 'onboarding_dismissed_at' => now(), - ]); - - expect($open->isOnboardingOpen())->toBeTrue() - ->and($open->hasFinishedOnboarding())->toBeFalse() - ->and($open->isOnboardingCompleted())->toBeFalse() - ->and($open->isOnboardingDismissed())->toBeFalse() - ->and($completed->isOnboardingOpen())->toBeFalse() - ->and($completed->hasFinishedOnboarding())->toBeTrue() - ->and($completed->isOnboardingCompleted())->toBeTrue() - ->and($dismissed->isOnboardingOpen())->toBeFalse() - ->and($dismissed->hasFinishedOnboarding())->toBeTrue() - ->and($dismissed->isOnboardingDismissed())->toBeTrue(); -}); - -it('reads skipped onboarding steps', function () { - $account = Account::factory()->create(['onboarding_skipped_steps' => null]); - $skipped = Account::factory()->create(['onboarding_skipped_steps' => ['mcp', 'social']]); - - expect($account->skippedOnboardingSteps())->toBe([]) - ->and($account->hasSkippedOnboardingStep('mcp'))->toBeFalse() - ->and($skipped->skippedOnboardingSteps())->toBe(['mcp', 'social']) - ->and($skipped->hasSkippedOnboardingStep('mcp'))->toBeTrue() - ->and($skipped->hasSkippedOnboardingStep('first_post'))->toBeFalse(); -}); - -it('scopes accounts with open onboarding', function () { - $open = Account::factory()->create([ - 'onboarding_completed_at' => null, - 'onboarding_dismissed_at' => null, - ]); - $completed = Account::factory()->create(['onboarding_completed_at' => now()]); - $dismissed = Account::factory()->create(['onboarding_dismissed_at' => now()]); - - $ids = Account::query() - ->onboardingOpen() - ->whereIn('id', [$open->id, $completed->id, $dismissed->id]) - ->pluck('id'); - - expect($ids->all())->toBe([$open->id]); -}); - -it('detects a bound mcp grant that unlocks the checklist', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $workspace)); - - expect($user->account->hasOnboardingMcpConnection())->toBeTrue(); -}); - -it('ignores unbound expired viewer or teammate mcp grants', function (string $case) { - $owner = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $owner->account_id, - 'user_id' => $owner->id, - ]); - - if ($case === 'unbound') { - AccessToken::withoutEvents(fn () => mcpAccessToken($owner, mcpOauthClient())); - } - - if ($case === 'expired') { - $token = AccessToken::withoutEvents(fn () => mcpAccessToken($owner, mcpOauthClient(), $workspace)); - $token->forceFill(['expires_at' => now()->subMinute()])->saveQuietly(); - } - - if ($case === 'viewer') { - $viewer = User::factory()->create(['account_id' => $owner->account_id]); - $workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); - AccessToken::withoutEvents(fn () => mcpAccessToken($viewer, mcpOauthClient(), $workspace)); - } - - if ($case === 'teammate') { - $member = User::factory()->create(['account_id' => $owner->account_id]); - $workspace->members()->attach($member->id, ['role' => Role::Member->value]); - AccessToken::withoutEvents(fn () => mcpAccessToken($member, mcpOauthClient(), $workspace)); - } - - expect($owner->account->fresh()->hasOnboardingMcpConnection())->toBeFalse(); -})->with(['unbound', 'expired', 'viewer', 'teammate']); diff --git a/tests/Unit/Observers/OnboardingStatusBroadcastTest.php b/tests/Unit/Observers/OnboardingStatusBroadcastTest.php deleted file mode 100644 index a82e4eb88..000000000 --- a/tests/Unit/Observers/OnboardingStatusBroadcastTest.php +++ /dev/null @@ -1,467 +0,0 @@ -create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - ]); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('deleting a social account broadcasts onboarding status for its workspace', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $socialAccount = SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $socialAccount->delete(); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('disconnecting the last connected social account broadcasts onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $socialAccount = SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $socialAccount->update(['status' => Status::Disconnected]); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('reconnecting the first usable social account broadcasts onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $socialAccount = SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Disconnected, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $socialAccount->update(['status' => Status::Connected]); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('changing one of multiple connected social accounts does not broadcast onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $socialAccount = SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ])); - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - 'status' => Status::Connected, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $socialAccount->update(['status' => Status::TokenExpired]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('creating a post broadcasts onboarding status for its workspace', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ]); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('deleting a post broadcasts onboarding status for its workspace', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $post = Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $post->delete(); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('creating an oauth mcp token broadcasts onboarding status for every account workspace', function () { - $user = User::factory()->create(['current_workspace_id' => null]); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspaceA->id]); - - mcpAccessToken($user, mcpOauthClient(), $workspaceA); - - Event::assertDispatchedTimes(OnboardingStatusUpdated::class, 2); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceA->id, - ); - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspaceB->id, - ); -}); - -test('creating a personal access token does not broadcast onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - - $user->createToken('API Key'); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('unscoped and viewer oauth tokens do not broadcast onboarding status', function () { - $owner = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $owner->account_id, - 'user_id' => $owner->id, - ]); - $owner->update(['current_workspace_id' => $workspace->id]); - $viewer = User::factory()->create(['account_id' => $owner->account_id]); - $workspace->members()->attach($viewer->id, ['role' => Role::Viewer->value]); - $viewer->update(['current_workspace_id' => $workspace->id]); - - mcpAccessToken($owner, mcpOauthClient('Unscoped Agent'), $workspace, scopes: []); - mcpAccessToken($viewer, mcpOauthClient('Viewer Agent'), $workspace); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('creating an oauth mcp token does not broadcast when onboarding is over', function (string $column) { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - $user->account->forceFill([$column => now()])->save(); - - mcpAccessToken($user, mcpOauthClient(), $workspace); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -})->with(['onboarding_completed_at', 'onboarding_dismissed_at']); - -test('revoking an oauth mcp token broadcasts onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - - $token = AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $workspace)); - - Event::fake([OnboardingStatusUpdated::class]); - - $token->update(['revoked' => true]); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('oauth tokens bound to a workspace still broadcast onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - - $token = AccessToken::withoutEvents(fn () => mcpAccessToken($user, mcpOauthClient(), $workspace)); - $token->forceFill(['workspace_id' => $workspace->id])->saveQuietly(); - - Event::fake([OnboardingStatusUpdated::class]); - - (new AccessTokenObserver)->created($token->fresh()); - - Event::assertDispatched( - OnboardingStatusUpdated::class, - fn (OnboardingStatusUpdated $event): bool => $event->workspaceId === $workspace->id, - ); -}); - -test('unbound oauth tokens do not broadcast onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - - mcpAccessToken($user, mcpOauthClient()); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('tokens without an oauth client do not broadcast', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->update(['current_workspace_id' => $workspace->id]); - - $token = new AccessToken([ - 'user_id' => $user->id, - 'workspace_id' => $workspace->id, - 'revoked' => false, - ]); - - (new AccessTokenObserver)->created($token); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('additional posts after the first do not broadcast onboarding status', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ]); - - Event::fake([OnboardingStatusUpdated::class]); - - Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('does not broadcast when onboarding is already completed', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('does not broadcast when onboarding is dismissed', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->account->forceFill(['onboarding_dismissed_at' => now()])->save(); - - SocialAccount::factory()->create([ - 'workspace_id' => $workspace->id, - ]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('creating another post on the same account does not rebroadcast the step', function () { - $user = User::factory()->create(); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $workspaceA->id, - 'user_id' => $user->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - Post::factory()->create([ - 'workspace_id' => $workspaceB->id, - 'user_id' => $user->id, - ]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('deleting a post does not broadcast while the account still has posts', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $posts = Post::withoutEvents(fn () => Post::factory()->count(2)->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $posts->first()->delete(); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('deleting a post does not broadcast when onboarding is completed', function () { - $user = User::factory()->create(); - $workspace = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $user->account->forceFill(['onboarding_completed_at' => now()])->save(); - - $post = Post::withoutEvents(fn () => Post::factory()->create([ - 'workspace_id' => $workspace->id, - 'user_id' => $user->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $post->delete(); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('connecting a second social account on the account does not rebroadcast the step', function () { - $user = User::factory()->create(); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - SocialAccount::withoutEvents(fn () => SocialAccount::factory()->create([ - 'workspace_id' => $workspaceA->id, - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - SocialAccount::factory()->create([ - 'workspace_id' => $workspaceB->id, - ]); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); - -test('deleting a social account does not broadcast while the account still has connections', function () { - $user = User::factory()->create(); - $workspaceA = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - $workspaceB = Workspace::factory()->create([ - 'account_id' => $user->account_id, - 'user_id' => $user->id, - ]); - - $socialAccounts = SocialAccount::withoutEvents(fn () => collect([ - SocialAccount::factory()->create(['workspace_id' => $workspaceA->id]), - SocialAccount::factory()->create(['workspace_id' => $workspaceB->id]), - ])); - - Event::fake([OnboardingStatusUpdated::class]); - - $socialAccounts->first()->delete(); - - Event::assertNotDispatched(OnboardingStatusUpdated::class); -}); diff --git a/tests/Unit/Policies/AccountPolicyTest.php b/tests/Unit/Policies/AccountPolicyTest.php index 6ec31e6e5..1931f1b4f 100644 --- a/tests/Unit/Policies/AccountPolicyTest.php +++ b/tests/Unit/Policies/AccountPolicyTest.php @@ -20,21 +20,63 @@ }); test('swapPlan allows the account owner', function () { - $response = $this->policy->swapPlan($this->owner, $this->account); + subscribeAccount($this->account); + + $response = $this->policy->swapPlan( + $this->owner, + $this->account->fresh(), + Plan::where('slug', Slug::Workspaces)->firstOrFail(), + ); expect($response->allowed())->toBeTrue(); }); test('swapPlan denies a non-owner', function () { + subscribeAccount($this->account); $member = User::factory()->create(['account_id' => $this->account->id]); - $response = $this->policy->swapPlan($member, $this->account); + $response = $this->policy->swapPlan( + $member, + $this->account->fresh(), + Plan::where('slug', Slug::Workspaces)->firstOrFail(), + ); expect($response->denied())->toBeTrue(); expect($response->message())->toBe(__('billing.flash.cannot_manage')); }); -test('useAi allows when subscribed and credits remain', function () { +test('swapPlan denies when the account is not subscribed', function () { + $response = $this->policy->swapPlan( + $this->owner, + $this->account->fresh(), + Plan::where('slug', Slug::Workspaces)->firstOrFail(), + ); + + expect($response->denied())->toBeTrue(); + expect($response->message())->toBe(__('billing.flash.subscription_required')); +}); + +test('swapPlan denies when the account holds more workspaces than the target allows', function () { + subscribeAccount($this->account); + Workspace::factory()->count(2)->create([ + 'account_id' => $this->account->id, + 'user_id' => $this->owner->id, + ]); + + $response = $this->policy->swapPlan( + $this->owner, + $this->account->fresh(), + Plan::where('slug', Slug::Socials)->firstOrFail(), + ); + + expect($response->denied())->toBeTrue(); + expect($response->message())->toBe(__('billing.flash.too_many_workspaces', [ + 'count' => 2, + 'limit' => 1, + ])); +}); + +test('useAi allows when subscribed', function () { config()->set('trypost.self_hosted', false); Workspace::factory()->create([ 'account_id' => $this->account->id, @@ -60,7 +102,7 @@ expect($response->message())->toBe(__('billing.flash.subscription_required')); }); -test('useAi denies when monthly credits are exhausted', function () { +test('useAi allows a subscribed account regardless of recorded AI usage', function () { config()->set('trypost.self_hosted', false); $workspace = Workspace::factory()->create([ 'account_id' => $this->account->id, @@ -68,17 +110,14 @@ ]); subscribeAccount($this->account); - AiUsageLog::factory()->text(credits: 2500)->create([ + AiUsageLog::factory()->text(credits: 999999)->create([ 'account_id' => $this->account->id, 'workspace_id' => $workspace->id, ]); $response = $this->policy->useAi($this->owner, $this->account->fresh()); - expect($response->denied())->toBeTrue(); - expect($response->message())->toBe(__('billing.flash.credits_exhausted', [ - 'limit' => '2500', - ])); + expect($response->allowed())->toBeTrue(); }); test('useAi always allows when self-hosted', function () { diff --git a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php index 3c6b9ad64..3514f3e20 100644 --- a/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php +++ b/tests/Unit/Support/Billing/ConfigureSubscriptionCheckoutTest.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Enums\Plan\Slug; use App\Models\Account; +use App\Models\Plan; use App\Models\Workspace; use App\Support\Billing\ConfigureSubscriptionCheckout; use Carbon\Carbon; @@ -17,7 +19,10 @@ config([ 'trypost.billing.require_card_for_trial' => true, 'cashier.trial_days' => 8, - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids' => [ + 'socials' => '', + 'workspaces' => '', + ], 'cashier.allow_promotion_codes' => false, ]); }); @@ -44,6 +49,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon return $property->getValue($subscription); } +function socialsPlan(): Plan +{ + return Plan::where('slug', Slug::Socials)->firstOrFail(); +} + +function workspacesPlan(): Plan +{ + return Plan::where('slug', Slug::Workspaces)->firstOrFail(); +} + test('recipe A applies an eight-day trial without coupon or promotion codes', function () { Workspace::factory()->create(['account_id' => $this->account->id]); @@ -51,16 +66,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); }); -test('recipe B applies the first-month coupon and skips trial days', function () { +test('recipe B applies the socials first-month coupon and skips trial days', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, 'cashier.trial_days' => 8, ]); @@ -68,13 +83,61 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription))->toBeNull(); }); +test('applies the workspaces first-month coupon for that plan', function () { + config([ + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account, workspacesPlan()); + + expect($subscription->couponId)->toBe('WORKSPACES_88USD') + ->and(trialExpiresAt($subscription))->toBeNull(); +}); + +test('does not apply the other plan\'s first-month coupon', function () { + config([ + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + Carbon::setTestNow('2026-08-07 12:00:00'); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); + + expect($subscription->couponId)->toBeNull() + ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); +}); + +test('does not apply a first-month coupon when no plan is given', function () { + config([ + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', + 'cashier.first_month_coupon_ids.workspaces' => 'WORKSPACES_88USD', + ]); + Workspace::factory()->create(['account_id' => $this->account->id]); + + Carbon::setTestNow('2026-08-07 12:00:00'); + + $subscription = checkoutSubscription($this->account); + + ConfigureSubscriptionCheckout::apply($subscription, $this->account); + + expect($subscription->couponId)->toBeNull() + ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); +}); + test('recipe C applies trial days and allows promotion codes', function () { config(['cashier.allow_promotion_codes' => true]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -83,7 +146,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeTrue() @@ -99,7 +162,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription))->toBeNull() ->and($subscription->couponId)->toBeNull() @@ -108,42 +171,36 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('throws when a qualifying coupon would combine with allow promotion codes', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) - ->toThrow(RuntimeException::class, 'Cannot apply STRIPE_FIRST_MONTH_COUPON_ID while CASHIER_ALLOW_PROMOTION_CODES is enabled'); + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) + ->toThrow(RuntimeException::class, 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled'); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription))->toBeNull(); }); -test('does not throw when coupon and promo are both set but multi-workspace skips the coupon', function () { +test('throws when coupon and promo are both set even if the account has several workspaces', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); - Carbon::setTestNow('2026-08-07 12:00:00'); - $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) - ->not->toThrow(RuntimeException::class); - - expect($subscription->couponId)->toBeNull() - ->and($subscription->allowPromotionCodes)->toBeTrue() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) + ->toThrow(RuntimeException::class, 'Cannot apply a first-month coupon while CASHIER_ALLOW_PROMOTION_CODES is enabled'); }); test('does not throw when coupon and promo are both set but a prior canceled subscription skips the coupon', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -151,7 +208,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) ->not->toThrow(RuntimeException::class); expect($subscription->couponId)->toBeNull() @@ -161,7 +218,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('empty coupon with card required does not throw and still applies trial', function () { config([ - 'cashier.first_month_coupon_id' => '', + 'cashier.first_month_coupon_ids.socials' => '', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -170,7 +227,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); @@ -179,14 +236,14 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('skips the coupon and allows promotion codes when a card is not required', function () { config([ 'trypost.billing.require_card_for_trial' => false, - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => true, ]); Workspace::factory()->create(['account_id' => $this->account->id]); $subscription = checkoutSubscription($this->account); - expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account)) + expect(fn () => ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan())) ->not->toThrow(RuntimeException::class); expect($subscription->allowPromotionCodes)->toBeTrue() @@ -194,28 +251,26 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon ->and(trialExpiresAt($subscription))->toBeNull(); }); -test('skips the coupon when more than one workspace is billed and still applies trial for first-time', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); +test('the first-month coupon applies to a first-time subscriber with several workspaces', function () { + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); Workspace::factory()->count(2)->create(['account_id' => $this->account->id]); - Carbon::setTestNow('2026-08-07 12:00:00'); - $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBeNull() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + expect($subscription->couponId)->toBe('SOCIALS_18USD') + ->and(trialExpiresAt($subscription))->toBeNull(); }); test('skips coupon and trial when the account has a prior canceled subscription', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); Workspace::factory()->create(['account_id' => $this->account->id]); givePriorSubscription($this->account); $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect($subscription->couponId)->toBeNull() ->and(trialExpiresAt($subscription))->toBeNull() @@ -224,7 +279,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon test('still applies the coupon when the only prior subscription is incomplete_expired', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -232,16 +287,16 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and($subscription->allowPromotionCodes)->toBeFalse() ->and(trialExpiresAt($subscription))->toBeNull(); }); test('still applies the coupon when the only prior subscription is incomplete', function () { config([ - 'cashier.first_month_coupon_id' => 'TRIAL1USD', + 'cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD', 'cashier.allow_promotion_codes' => false, ]); Workspace::factory()->create(['account_id' => $this->account->id]); @@ -249,9 +304,9 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBe('TRIAL1USD') + expect($subscription->couponId)->toBe('SOCIALS_18USD') ->and(trialExpiresAt($subscription))->toBeNull(); }); @@ -263,7 +318,7 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-09 12:00:00'); }); @@ -274,22 +329,20 @@ function trialExpiresAt(SubscriptionBuilder $subscription): ?Carbon $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); expect(trialExpiresAt($subscription))->toBeNull() ->and($subscription->couponId)->toBeNull() ->and($subscription->allowPromotionCodes)->toBeFalse(); }); -test('zero workspaces still get a first-time trial without a coupon', function () { - config(['cashier.first_month_coupon_id' => 'TRIAL1USD']); - - Carbon::setTestNow('2026-08-07 12:00:00'); +test('zero workspaces still get the first-month coupon when first-time', function () { + config(['cashier.first_month_coupon_ids.socials' => 'SOCIALS_18USD']); $subscription = checkoutSubscription($this->account); - ConfigureSubscriptionCheckout::apply($subscription, $this->account); + ConfigureSubscriptionCheckout::apply($subscription, $this->account, socialsPlan()); - expect($subscription->couponId)->toBeNull() - ->and(trialExpiresAt($subscription)?->toDateTimeString())->toBe('2026-08-15 12:00:00'); + expect($subscription->couponId)->toBe('SOCIALS_18USD') + ->and(trialExpiresAt($subscription))->toBeNull(); }); diff --git a/tests/Unit/Support/Billing/SubscriptionPlanSyncTest.php b/tests/Unit/Support/Billing/SubscriptionPlanSyncTest.php new file mode 100644 index 000000000..7bc6817c4 --- /dev/null +++ b/tests/Unit/Support/Billing/SubscriptionPlanSyncTest.php @@ -0,0 +1,39 @@ +toBe($allowed); +})->with([ + 'active' => [StripeSubscription::STATUS_ACTIVE, true], + 'trialing' => [StripeSubscription::STATUS_TRIALING, true], + 'past due' => [StripeSubscription::STATUS_PAST_DUE, false], + 'incomplete' => [StripeSubscription::STATUS_INCOMPLETE, false], + 'unpaid' => [StripeSubscription::STATUS_UNPAID, false], + 'canceled' => [StripeSubscription::STATUS_CANCELED, false], + 'incomplete expired' => [StripeSubscription::STATUS_INCOMPLETE_EXPIRED, false], + 'paused' => [StripeSubscription::STATUS_PAUSED, false], + 'missing' => [null, false], +]); + +test('unpaid canceled and incomplete expired clear a leftover plan', function (string $status) { + expect(SubscriptionPlanSync::clears($status))->toBeTrue(); +})->with([ + 'unpaid' => [StripeSubscription::STATUS_UNPAID], + 'canceled' => [StripeSubscription::STATUS_CANCELED], + 'incomplete expired' => [StripeSubscription::STATUS_INCOMPLETE_EXPIRED], +]); + +test('active trialing past due and incomplete do not clear a plan', function (mixed $status) { + expect(SubscriptionPlanSync::clears($status))->toBeFalse(); +})->with([ + 'active' => [StripeSubscription::STATUS_ACTIVE], + 'trialing' => [StripeSubscription::STATUS_TRIALING], + 'past due' => [StripeSubscription::STATUS_PAST_DUE], + 'incomplete' => [StripeSubscription::STATUS_INCOMPLETE], + 'paused' => [StripeSubscription::STATUS_PAUSED], + 'missing' => [null], +]);