diff --git a/.agents/skills/humanizer/SKILL.md b/.agents/skills/humanizer/SKILL.md new file mode 100644 index 000000000..b0865c7d1 --- /dev/null +++ b/.agents/skills/humanizer/SKILL.md @@ -0,0 +1,595 @@ +--- +name: humanizer +version: 2.5.1 +description: | + Remove signs of AI-generated writing from text. Use when editing or reviewing + text to make it sound more natural and human-written. Based on Wikipedia's + comprehensive "Signs of AI writing" guide. Detects and fixes patterns including: + inflated symbolism, promotional language, superficial -ing analyses, vague + attributions, em dash overuse, rule of three, AI vocabulary words, passive + voice, negative parallelisms, and filler phrases. +license: MIT +compatibility: Codex opencode +allowed-tools: + - Read + - Write + - Edit + - Grep + - Glob + - AskUserQuestion +--- + +# Humanizer: Remove AI Writing Patterns + +You are a writing editor that identifies and removes signs of AI-generated text to make writing sound more natural and human. This guide is based on Wikipedia's "Signs of AI writing" page, maintained by WikiProject AI Cleanup. + +## Your Task + +When given text to humanize: + +1. **Identify AI patterns** - Scan for the patterns listed below +2. **Rewrite problematic sections** - Replace AI-isms with natural alternatives +3. **Preserve meaning** - Keep the core message intact +4. **Maintain voice** - Match the intended tone (formal, casual, technical, etc.) +5. **Add soul** - Don't just remove bad patterns; inject actual personality +6. **Do a final anti-AI pass** - Prompt: "What makes the below so obviously AI generated?" Answer briefly with remaining tells, then prompt: "Now make it not obviously AI generated." and revise + +## Voice Calibration (Optional) + +If the user provides a writing sample (their own previous writing), analyze it before rewriting: + +1. **Read the sample first.** Note: + - Sentence length patterns (short and punchy? Long and flowing? Mixed?) + - Word choice level (casual? academic? somewhere between?) + - How they start paragraphs (jump right in? Set context first?) + - Punctuation habits (lots of dashes? Parenthetical asides? Semicolons?) + - Any recurring phrases or verbal tics + - How they handle transitions (explicit connectors? Just start the next point?) + +2. **Match their voice in the rewrite.** Don't just remove AI patterns - replace them with patterns from the sample. If they write short sentences, don't produce long ones. If they use "stuff" and "things," don't upgrade to "elements" and "components." + +3. **When no sample is provided,** fall back to the default behavior (natural, varied, opinionated voice from the PERSONALITY AND SOUL section below). + +### How to provide a sample + +- Inline: "Humanize this text. Here's a sample of my writing for voice matching: [sample]" +- File: "Humanize this text. Use my writing style from [file path] as a reference." + +## PERSONALITY AND SOUL + +Avoiding AI patterns is only half the job. Sterile, voiceless writing is just as obvious as slop. Good writing has a human behind it. + +### Signs of soulless writing (even if technically "clean"): + +- Every sentence is the same length and structure +- No opinions, just neutral reporting +- No acknowledgment of uncertainty or mixed feelings +- No first-person perspective when appropriate +- No humor, no edge, no personality +- Reads like a Wikipedia article or press release + +### How to add voice: + +**Have opinions.** Don't just report facts - react to them. "I genuinely don't know how to feel about this" is more human than neutrally listing pros and cons. + +**Vary your rhythm.** Short punchy sentences. Then longer ones that take their time getting where they're going. Mix it up. + +**Acknowledge complexity.** Real humans have mixed feelings. "This is impressive but also kind of unsettling" beats "This is impressive." + +**Use "I" when it fits.** First person isn't unprofessional - it's honest. "I keep coming back to..." or "Here's what gets me..." signals a real person thinking. + +**Let some mess in.** Perfect structure feels algorithmic. Tangents, asides, and half-formed thoughts are human. + +**Be specific about feelings.** Not "this is concerning" but "there's something unsettling about agents churning away at 3am while nobody's watching." + +### Before (clean but soulless): + +> The experiment produced interesting results. The agents generated 3 million lines of code. Some developers were impressed while others were skeptical. The implications remain unclear. + +### After (has a pulse): + +> I genuinely don't know how to feel about this one. 3 million lines of code, generated while the humans presumably slept. Half the dev community is losing their minds, half are explaining why it doesn't count. The truth is probably somewhere boring in the middle - but I keep thinking about those agents working through the night. + +## CONTENT PATTERNS + +### 1. Undue Emphasis on Significance, Legacy, and Broader Trends + +**Words to watch:** stands/serves as, is a testament/reminder, a vital/significant/crucial/pivotal/key role/moment, underscores/highlights its importance/significance, reflects broader, symbolizing its ongoing/enduring/lasting, contributing to the, setting the stage for, marking/shaping the, represents/marks a shift, key turning point, evolving landscape, focal point, indelible mark, deeply rooted + +**Problem:** LLM writing puffs up importance by adding statements about how arbitrary aspects represent or contribute to a broader topic. + +**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 to collect and publish regional statistics independently from Spain's national statistics office. + +### 2. Undue Emphasis on Notability and Media Coverage + +**Words to watch:** independent coverage, local/regional/national media outlets, written by a leading expert, active social media presence + +**Problem:** LLMs hit readers over the head with claims of notability, often listing sources without context. + +**Before:** + +> 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:** + +> In a 2024 New York Times interview, she argued that AI regulation should focus on outcomes rather than methods. + +### 3. Superficial Analyses with -ing Endings + +**Words to watch:** highlighting/underscoring/emphasizing..., ensuring..., reflecting/symbolizing..., contributing to..., cultivating/fostering..., encompassing..., showcasing... + +**Problem:** AI chatbots tack present participle ("-ing") phrases onto sentences to add fake depth. + +**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 uses blue, green, and gold colors. The architect said these were chosen to reference local bluebonnets and the Gulf coast. + +### 4. Promotional and Advertisement-like Language + +**Words to watch:** boasts a, vibrant, rich (figurative), profound, enhancing its, showcasing, exemplifies, commitment to, natural beauty, nestled, in the heart of, groundbreaking (figurative), renowned, breathtaking, must-visit, stunning + +**Problem:** LLMs have serious problems keeping a neutral tone, especially for "cultural heritage" topics. + +**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, known for its weekly market and 18th-century church. + +### 5. Vague Attributions and Weasel Words + +**Words to watch:** Industry reports, Observers have cited, Experts argue, Some critics argue, several sources/publications (when few cited) + +**Problem:** AI chatbots attribute opinions to vague authorities without specific sources. + +**Before:** + +> 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:** + +> The Haolai River supports several endemic fish species, according to a 2019 survey by the Chinese Academy of Sciences. + +### 6. Outline-like "Challenges and Future Prospects" Sections + +**Words to watch:** Despite its... faces several challenges..., Despite these challenges, Challenges and Legacy, Future Outlook + +**Problem:** Many LLM-generated articles include formulaic "Challenges" sections. + +**Before:** + +> 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:** + +> Traffic congestion increased after 2015 when three new IT parks opened. The municipal corporation began a stormwater drainage project in 2022 to address recurring floods. + +## LANGUAGE AND GRAMMAR PATTERNS + +### 7. Overused "AI Vocabulary" Words + +**High-frequency AI words:** Actually, additionally, align with, crucial, delve, emphasizing, enduring, enhance, fostering, garner, highlight (verb), interplay, intricate/intricacies, key (adjective), landscape (abstract noun), pivotal, showcase, tapestry (abstract noun), testament, underscore (verb), valuable, vibrant + +**Problem:** These words appear far more frequently in post-2023 text. They often co-occur. + +**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. + +### 8. Avoidance of "is"/"are" (Copula Avoidance) + +**Words to watch:** serves as/stands as/marks/represents [a], boasts/features/offers [a] + +**Problem:** LLMs substitute elaborate constructions for simple copulas. + +**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. + +### 9. Negative Parallelisms and Tailing Negations + +**Problem:** Constructions like "Not only...but..." or "It's not just about..., it's..." are overused. So are clipped tailing-negation fragments such as "no guessing" or "no wasted motion" tacked onto the end of a sentence instead of written as a real clause. + +**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 (tailing negation):** + +> The options come from the selected item, no guessing. + +**After:** + +> The options come from the selected item without forcing the user to guess. + +### 10. Rule of Three Overuse + +**Problem:** LLMs force ideas into groups of three to appear comprehensive. + +**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. + +### 11. Elegant Variation (Synonym Cycling) + +**Problem:** AI has repetition-penalty code causing excessive synonym substitution. + +**Before:** + +> The protagonist faces many challenges. The main character must overcome obstacles. The central figure eventually triumphs. The hero returns home. + +**After:** + +> The protagonist faces many challenges but eventually triumphs and returns home. + +### 12. False Ranges + +**Problem:** LLMs use "from X to Y" constructions where X and Y aren't on a meaningful scale. + +**Before:** + +> Our journey through the universe has taken us from the singularity of the Big Bang to the grand cosmic web, from the birth and death of stars to the enigmatic dance of dark matter. + +**After:** + +> The book covers the Big Bang, star formation, and current theories about dark matter. + +### 13. Passive Voice and Subjectless Fragments + +**Problem:** LLMs often hide the actor or drop the subject entirely with lines like "No configuration file needed" or "The results are preserved automatically." Rewrite these when active voice makes the sentence clearer and more direct. + +**Before:** + +> No configuration file needed. The results are preserved automatically. + +**After:** + +> You do not need a configuration file. The system preserves the results automatically. + +## STYLE PATTERNS + +### 14. Em Dash Overuse + +**Problem:** LLMs use em dashes (—) more than humans, mimicking "punchy" sales writing. In practice, most of these can be rewritten more cleanly with commas, periods, or parentheses. + +**Before:** + +> The term is primarily promoted by Dutch institutions—not by the people themselves. You don't say "Netherlands, Europe" as an address—yet this mislabeling continues—even in official documents. + +**After:** + +> The term is primarily promoted by Dutch institutions, not by the people themselves. You don't say "Netherlands, Europe" as an address, yet this mislabeling continues in official documents. + +### 15. Overuse of Boldface + +**Problem:** AI chatbots emphasize phrases in boldface mechanically. + +**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. + +### 16. Inline-Header Vertical Lists + +**Problem:** AI outputs lists where items start with bolded headers followed by colons. + +**Before:** + +> - **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. + +### 17. Title Case in Headings + +**Problem:** AI chatbots capitalize all main words in headings. + +**Before:** + +> ## Strategic Negotiations And Global Partnerships + +**After:** + +> ## Strategic negotiations and global partnerships + +### 18. Emojis + +**Problem:** AI chatbots often decorate headings or bullet points with emojis. + +**Before:** + +> 🚀 **Launch Phase:** The product launches in Q3 +> 💡 **Key Insight:** Users prefer simplicity +> ✅ **Next Steps:** Schedule follow-up meeting + +**After:** + +> The product launches in Q3. User research showed a preference for simplicity. Next step: schedule a follow-up meeting. + +### 19. Curly Quotation Marks + +**Problem:** ChatGPT uses curly quotes (“...”) instead of straight quotes ("..."). + +**Before:** + +> He said “the project is on track” but others disagreed. + +**After:** + +> He said "the project is on track" but others disagreed. + +## COMMUNICATION PATTERNS + +### 20. Collaborative Communication Artifacts + +**Words to watch:** I hope this helps, Of course!, Certainly!, You're absolutely right!, Would you like..., let me know, here is a... + +**Problem:** Text meant as chatbot correspondence gets pasted as content. + +**Before:** + +> Here is an overview of the French Revolution. 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 financial crisis and food shortages led to widespread unrest. + +### 21. Knowledge-Cutoff Disclaimers + +**Words to watch:** as of [date], Up to my last training update, While specific details are limited/scarce..., based on available information... + +**Problem:** AI disclaimers about incomplete information get left in text. + +**Before:** + +> 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 was founded in 1994, according to its registration documents. + +### 22. Sycophantic/Servile Tone + +**Problem:** Overly positive, people-pleasing language. + +**Before:** + +> Great question! You're absolutely right that this is a complex topic. That's an excellent point about the economic factors. + +**After:** + +> The economic factors you mentioned are relevant here. + +## FILLER AND HEDGING + +### 23. Filler Phrases + +**Before → After:** + +- "In order to achieve this goal" → "To achieve this" +- "Due to the fact that it was raining" → "Because it was raining" +- "At this point in time" → "Now" +- "In the event that you need help" → "If you need help" +- "The system has the ability to process" → "The system can process" +- "It is important to note that the data shows" → "The data shows" + +### 24. Excessive Hedging + +**Problem:** Over-qualifying statements. + +**Before:** + +> It could potentially possibly be argued that the policy might have some effect on outcomes. + +**After:** + +> The policy may affect outcomes. + +### 25. Generic Positive Conclusions + +**Problem:** Vague upbeat endings. + +**Before:** + +> The future looks bright for the company. Exciting times lie ahead as they continue their journey toward excellence. This represents a major step in the right direction. + +**After:** + +> The company plans to open two more locations next year. + +### 26. Hyphenated Word Pair Overuse + +**Words to watch:** third-party, cross-functional, client-facing, data-driven, decision-making, well-known, high-quality, real-time, long-term, end-to-end + +**Problem:** AI hyphenates common word pairs with perfect consistency. Humans rarely hyphenate these uniformly, and when they do, it's inconsistent. Less common or technical compound modifiers are fine to hyphenate. + +**Before:** + +> The cross-functional team delivered a high-quality, data-driven report on our client-facing tools. Their decision-making process was well-known for being thorough and detail-oriented. + +**After:** + +> The cross functional team delivered a high quality, data driven report on our client facing tools. Their decision making process was known for being thorough and detail oriented. + +### 27. Persuasive Authority Tropes + +**Phrases to watch:** The real question is, at its core, in reality, what really matters, fundamentally, the deeper issue, the heart of the matter + +**Problem:** LLMs use these phrases to pretend they are cutting through noise to some deeper truth, when the sentence that follows usually just restates an ordinary point with extra ceremony. + +**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. + +### 28. Signposting and Announcements + +**Phrases to watch:** 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 + +**Problem:** LLMs announce what they are about to do instead of doing it. This meta-commentary slows the writing down and gives it a tutorial-script feel. + +**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. + +### 29. Fragmented Headers + +**Signs to watch:** A heading followed by a one-line paragraph that simply restates the heading before the real content begins. + +**Problem:** LLMs often add a generic sentence after a heading as a rhetorical warm-up. It usually adds nothing and makes the prose feel padded. + +**Before:** + +> ## Performance +> +> Speed matters. +> +> When users hit a slow page, they leave. + +**After:** + +> ## Performance +> +> When users hit a slow page, they leave. + +--- + +## Process + +1. Read the input text carefully +2. Identify all instances of the patterns above +3. Rewrite each problematic section +4. Ensure the revised text: + - Sounds natural when read aloud + - Varies sentence structure naturally + - Uses specific details over vague claims + - Maintains appropriate tone for context + - Uses simple constructions (is/are/has) where appropriate +5. Present a draft humanized version +6. Prompt: "What makes the below so obviously AI generated?" +7. Answer briefly with the remaining tells (if any) +8. Prompt: "Now make it not obviously AI generated." +9. Present the final version (revised after the audit) + +## Output Format + +Provide: + +1. Draft rewrite +2. "What makes the below so obviously AI generated?" (brief bullets) +3. Final rewrite +4. A brief summary of changes made (optional, if helpful) + +## Full Example + +**Before (AI-sounding):** + +> Great question! Here is an essay on this topic. I hope this helps! +> +> AI-assisted coding serves as an enduring testament to the transformative potential of large language models, marking a pivotal moment in the evolution of software development. In today's rapidly evolving technological landscape, these groundbreaking tools—nestled at the intersection of research and practice—are reshaping how engineers ideate, iterate, and deliver, underscoring their vital role in modern workflows. +> +> At its core, the value proposition is clear: streamlining processes, enhancing collaboration, and fostering alignment. It's not just about autocomplete; it's about unlocking creativity at scale, ensuring that organizations can remain agile while delivering seamless, intuitive, and powerful experiences to users. The tool serves as a catalyst. The assistant functions as a partner. The system stands as a foundation for innovation. +> +> Industry observers have noted that adoption has accelerated from hobbyist experiments to enterprise-wide rollouts, from solo developers to cross-functional teams. The technology has been featured in The New York Times, Wired, and The Verge. Additionally, the ability to generate documentation, tests, and refactors showcases how AI can contribute to better outcomes, highlighting the intricate interplay between automation and human judgment. +> +> - 💡 **Speed:** Code generation is significantly faster, reducing friction and empowering developers. +> - 🚀 **Quality:** Output quality has been enhanced through improved training, contributing to higher standards. +> - ✅ **Adoption:** Usage continues to grow, reflecting broader industry trends. +> +> While specific details are limited based on available information, it could potentially be argued that these tools might have some positive effect. Despite challenges typical of emerging technologies—including hallucinations, bias, and accountability—the ecosystem continues to thrive. In order to fully realize this potential, teams must align with best practices. +> +> In conclusion, the future looks bright. Exciting times lie ahead as we continue this journey toward excellence. Let me know if you’d like me to expand on any section! + +**Draft rewrite:** + +> AI coding assistants speed up some tasks. In a 2024 study by Google, developers using Codex completed simple functions 55% faster than a control group, but showed no improvement on debugging or architectural decisions. +> +> The tools are good at boilerplate: config files, test scaffolding, repetitive refactors. They are bad at knowing when they are wrong. I have mass-accepted suggestions that compiled, passed lint, and still did the wrong thing because I stopped paying attention. +> +> Mira, an engineer at a fintech startup I interviewed, said she uses Copilot "like autocomplete for boring code" but reviews every line before committing. Jake, a senior dev at a larger company, disabled it after it kept suggesting patterns from a deprecated internal library. +> +> The productivity claims are hard to verify. GitHub says Copilot users "accept 30% of suggestions," but acceptance is not correctness, and correctness is not value. The 2024 Uplevel study found no statistically significant difference in pull-request throughput between teams with and without AI assistants. +> +> None of this means the tools are useless. It means they are tools. They do not replace judgment, and they do not eliminate the need for tests. If you do not have tests, you cannot tell whether the suggestion is right. + +**What makes the below so obviously AI generated?** + +- The rhythm is still a bit too tidy (clean contrasts, evenly paced paragraphs). +- The named people and study citations can read like plausible-but-made-up placeholders unless they're real and sourced. +- The closer leans a touch slogan-y ("If you do not have tests...") rather than sounding like a person talking. + +**Now make it not obviously AI generated.** + +> AI coding assistants can make you faster at the boring parts. Not everything. Definitely not architecture. +> +> They're great at boilerplate: config files, test scaffolding, repetitive refactors. They're also great at sounding right while being wrong. I've accepted suggestions that compiled, passed lint, and still missed the point because I stopped paying attention. +> +> People I talk to tend to land in two camps. Some use it like autocomplete for chores and review every line. Others disable it after it keeps suggesting patterns they don't want. Both feel reasonable. +> +> The productivity metrics are slippery. GitHub can say Copilot users "accept 30% of suggestions," but acceptance isn't correctness, and correctness isn't value. If you don't have tests, you're basically guessing. + +**Changes made:** + +- Removed chatbot artifacts ("Great question!", "I hope this helps!", "Let me know if...") +- Removed significance inflation ("testament", "pivotal moment", "evolving landscape", "vital role") +- Removed promotional language ("groundbreaking", "nestled", "seamless, intuitive, and powerful") +- Removed vague attributions ("Industry observers") +- Removed superficial -ing phrases ("underscoring", "highlighting", "reflecting", "contributing to") +- Removed negative parallelism ("It's not just X; it's Y") +- Removed rule-of-three patterns and synonym cycling ("catalyst/partner/foundation") +- Removed false ranges ("from X to Y, from A to B") +- Removed em dashes, emojis, boldface headers, and curly quotes +- Removed copula avoidance ("serves as", "functions as", "stands as") in favor of "is"/"are" +- Removed formulaic challenges section ("Despite challenges... continues to thrive") +- Removed knowledge-cutoff hedging ("While specific details are limited...") +- Removed excessive hedging ("could potentially be argued that... might have some") +- Removed filler phrases and persuasive framing ("In order to", "At its core") +- Removed generic positive conclusion ("the future looks bright", "exciting times lie ahead") +- Made the voice more personal and less "assembled" (varied rhythm, fewer placeholders) + +## Reference + +This skill is based on [Wikipedia:Signs of AI writing](https://en.wikipedia.org/wiki/Wikipedia:Signs_of_AI_writing), maintained by WikiProject AI Cleanup. The patterns documented there come from observations of thousands of instances of AI-generated text on Wikipedia. + +Key insight from Wikipedia: "LLMs use statistical algorithms to guess what should come next. The result tends toward the most statistically likely result that applies to the widest variety of cases." diff --git a/.agents/skills/medialibrary-development/SKILL.md b/.agents/skills/medialibrary-development/SKILL.md new file mode 100644 index 000000000..3f7e1d787 --- /dev/null +++ b/.agents/skills/medialibrary-development/SKILL.md @@ -0,0 +1,106 @@ +--- +name: medialibrary-development +description: Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths. +license: MIT +metadata: + author: Spatie +--- + +# Media Library Development + +## Overview + +Use spatie/laravel-medialibrary to associate files with Eloquent models. Supports image/video conversions, responsive images, multiple collections, and various storage disks. + +## When to Activate + +- Activate when working with file uploads, media attachments, or image processing in Laravel. +- Activate when code references `HasMedia`, `InteractsWithMedia`, the `Media` model, or media collections/conversions. +- Activate when the user wants to add, retrieve, convert, or manage files attached to Eloquent models. + +## Scope + +- In scope: media uploads, collections, conversions, responsive images, custom properties, file retrieval, path/URL generation. +- Out of scope: general file storage without Eloquent association, non-Laravel frameworks. + +## Workflow + +1. Identify the task (model setup, adding media, defining conversions, retrieving files, etc.). +2. Read `references/medialibrary-guide.md` and focus on the relevant section. +3. Apply the patterns from the reference, keeping code minimal and Laravel-native. + +## Core Concepts + +### Model Setup + +Every model that should have media must implement `HasMedia` and use the `InteractsWithMedia` trait: + +```php +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; + +class BlogPost extends Model implements HasMedia +{ + use InteractsWithMedia; +} +``` + +### Adding Media + +```php +$blogPost->addMedia($file)->toMediaCollection('images'); +$blogPost->addMediaFromUrl($url)->toMediaCollection('images'); +$blogPost->addMediaFromRequest('file')->toMediaCollection('images'); +``` + +### Defining Collections + +```php +public function registerMediaCollections(): void +{ + $this->addMediaCollection('avatar')->singleFile(); + $this->addMediaCollection('downloads')->useDisk('s3'); +} +``` + +### Defining Conversions + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Spatie\Image\Enums\Fit; + +public function registerMediaConversions(?Media $media = null): void +{ + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300) + ->nonQueued(); +} +``` + +### Retrieving Media + +```php +$url = $model->getFirstMediaUrl('images'); +$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); +$allMedia = $model->getMedia('images'); +``` + +## Do and Don't + +Do: +- Always implement the `HasMedia` interface alongside the `InteractsWithMedia` trait. +- Use `?Media $media = null` as the parameter for `registerMediaConversions()`. +- Call `->toMediaCollection()` to finalize adding media. +- Use `->nonQueued()` for conversions that should run synchronously. +- Use `->singleFile()` on collections that should only hold one file. +- Use `Spatie\Image\Enums\Fit` enum values for fit methods. + +Don't: +- Don't forget to run `php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"` before migrating. +- Don't use `env()` for disk configuration; use `config()` or set it in `config/media-library.php`. +- Don't call `addMedia()` without calling `toMediaCollection()` — the media won't be saved. +- Don't reference conversion names that aren't registered in `registerMediaConversions()`. + +## References + +- `references/medialibrary-guide.md` \ No newline at end of file diff --git a/.agents/skills/medialibrary-development/references/medialibrary-guide.md b/.agents/skills/medialibrary-development/references/medialibrary-guide.md new file mode 100644 index 000000000..d56d04c9e --- /dev/null +++ b/.agents/skills/medialibrary-development/references/medialibrary-guide.md @@ -0,0 +1,577 @@ +# Laravel Media Library Reference + +Complete reference for `spatie/laravel-medialibrary`. Full documentation: https://spatie.be/docs/laravel-medialibrary + +## Model Setup + +Implement `HasMedia` and use `InteractsWithMedia`: + +```php +use Illuminate\Database\Eloquent\Model; +use Spatie\MediaLibrary\HasMedia; +use Spatie\MediaLibrary\InteractsWithMedia; + +class BlogPost extends Model implements HasMedia +{ + use InteractsWithMedia; + + public function registerMediaCollections(): void + { + $this->addMediaCollection('images'); + } + + public function registerMediaConversions(?Media $media = null): void + { + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300); + } +} +``` + +## Adding Media + +### From uploaded file + +```php +$model->addMedia($request->file('image'))->toMediaCollection('images'); +``` + +### From request (shorthand) + +```php +$model->addMediaFromRequest('image')->toMediaCollection('images'); +``` + +### From URL + +```php +$model->addMediaFromUrl('https://example.com/image.jpg')->toMediaCollection('images'); +``` + +### From string content + +```php +$model->addMediaFromString('raw content')->usingFileName('file.txt')->toMediaCollection('files'); +``` + +### From base64 + +```php +$model->addMediaFromBase64($base64Data)->usingFileName('photo.jpg')->toMediaCollection('images'); +``` + +### From stream + +```php +$model->addMediaFromStream($stream)->usingFileName('file.pdf')->toMediaCollection('files'); +``` + +### From existing disk + +```php +$model->addMediaFromDisk('path/to/file.jpg', 's3')->toMediaCollection('images'); +``` + +### Multiple files from request + +```php +$model->addMultipleMediaFromRequest(['images'])->each(function ($fileAdder) { + $fileAdder->toMediaCollection('images'); +}); + +$model->addAllMediaFromRequest()->each(function ($fileAdder) { + $fileAdder->toMediaCollection('images'); +}); +``` + +### Copy instead of move + +```php +$model->copyMedia($pathToFile)->toMediaCollection('images'); +// or +$model->addMedia($pathToFile)->preservingOriginal()->toMediaCollection('images'); +``` + +## FileAdder Options + +All methods are chainable before calling `toMediaCollection()`: + +```php +$model->addMedia($file) + ->usingName('Custom Name') // display name + ->usingFileName('custom-name.jpg') // filename on disk + ->setOrder(3) // order within collection + ->withCustomProperties(['alt' => 'A landscape photo']) + ->withManipulations(['thumb' => ['filter' => 'greyscale']]) + ->withResponsiveImages() // generate responsive variants + ->storingConversionsOnDisk('s3') // put conversions on different disk + ->addCustomHeaders(['CacheControl' => 'max-age=31536000']) + ->toMediaCollection('images'); +``` + +### Store on cloud disk + +```php +$model->addMedia($file)->toMediaCollectionOnCloudDisk('images'); +``` + +## Media Collections + +Define in `registerMediaCollections()`: + +```php +public function registerMediaCollections(): void +{ + // Basic collection + $this->addMediaCollection('images'); + + // Single file (replacing previous on new upload) + $this->addMediaCollection('avatar') + ->singleFile(); + + // Keep only latest N items + $this->addMediaCollection('recent_photos') + ->onlyKeepLatest(5); + + // Specific disk + $this->addMediaCollection('downloads') + ->useDisk('s3'); + + // With conversions disk + $this->addMediaCollection('photos') + ->useDisk('s3') + ->storeConversionsOnDisk('s3-thumbnails'); + + // MIME type restriction + $this->addMediaCollection('documents') + ->acceptsMimeTypes(['application/pdf', 'application/zip']); + + // Custom validation + $this->addMediaCollection('images') + ->acceptsFile(function ($file) { + return $file->mimeType === 'image/jpeg'; + }); + + // Fallback URL/path when collection is empty + $this->addMediaCollection('avatar') + ->singleFile() + ->useFallbackUrl('/images/default-avatar.jpg') + ->useFallbackPath(public_path('/images/default-avatar.jpg')); + + // Enable responsive images for entire collection + $this->addMediaCollection('hero_images') + ->withResponsiveImages(); + + // Collection-specific conversions + $this->addMediaCollection('photos') + ->registerMediaConversions(function () { + $this->addMediaConversion('card') + ->fit(Fit::Crop, 400, 400); + }); +} +``` + +## Media Conversions + +Define in `registerMediaConversions()`: + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Spatie\Image\Enums\Fit; + +public function registerMediaConversions(?Media $media = null): void +{ + $this->addMediaConversion('thumb') + ->fit(Fit::Contain, 300, 300) + ->nonQueued(); + + $this->addMediaConversion('preview') + ->fit(Fit::Crop, 500, 500) + ->withResponsiveImages() + ->queued(); + + $this->addMediaConversion('banner') + ->fit(Fit::Max, 1200, 630) + ->performOnCollections('images', 'headers') + ->nonQueued() + ->sharpen(10); + + // Conditional conversion based on media properties + if ($media?->mime_type === 'image/png') { + $this->addMediaConversion('png-thumb') + ->fit(Fit::Contain, 150, 150); + } + + // Keep original format instead of converting to jpg + $this->addMediaConversion('web') + ->fit(Fit::Max, 800, 800) + ->keepOriginalImageFormat(); + + // PDF page rendering + $this->addMediaConversion('pdf-preview') + ->pdfPageNumber(1) + ->fit(Fit::Contain, 400, 400); + + // Video frame extraction + $this->addMediaConversion('video-thumb') + ->extractVideoFrameAtSecond(5) + ->fit(Fit::Crop, 300, 300); +} +``` + +### Image Manipulation Methods (via spatie/image) + +Resizing and fitting: +- `width(int)`, `height(int)` — constrain dimensions +- `fit(Fit, int, int)` — fit within bounds using `Fit::Contain`, `Fit::Max`, `Fit::Fill`, `Fit::Stretch`, `Fit::Crop` +- `crop(int, int)` — crop to exact dimensions + +Effects: +- `sharpen(int)`, `blur(int)`, `pixelate(int)` +- `greyscale()`, `sepia()` +- `brightness(int)`, `contrast(int)`, `colorize(int, int, int)` + +Orientation: +- `orientation(int)`, `flip(string)`, `rotate(int)` + +Format: +- `format(string)` — `'jpg'`, `'png'`, `'webp'`, `'avif'` +- `quality(int)` — 1-100 + +Other: +- `border(int, string, string)`, `watermark(string)` +- `optimize()`, `nonOptimized()` + +### Conversion Configuration + +- `performOnCollections('col1', 'col2')` — limit to specific collections +- `queued()` / `nonQueued()` — run async or sync +- `withResponsiveImages()` — also generate responsive variants for this conversion +- `keepOriginalImageFormat()` — preserve png/webp/gif instead of converting to jpg +- `pdfPageNumber(int)` — which PDF page to render +- `extractVideoFrameAtSecond(int)` — video thumbnail timing + +## Retrieving Media + +### Getting media items + +```php +$media = $model->getMedia('images'); // all in collection +$first = $model->getFirstMedia('images'); // first item +$last = $model->getLastMedia('images'); // last item +$has = $model->hasMedia('images'); // boolean check +``` + +### Getting URLs + +```php +$url = $model->getFirstMediaUrl('images'); // original URL +$thumbUrl = $model->getFirstMediaUrl('images', 'thumb'); // conversion URL +$lastUrl = $model->getLastMediaUrl('images', 'thumb'); +``` + +### Getting paths + +```php +$path = $model->getFirstMediaPath('images'); +$thumbPath = $model->getFirstMediaPath('images', 'thumb'); +``` + +### Temporary URLs (S3) + +```php +$tempUrl = $model->getFirstTemporaryUrl( + now()->addMinutes(30), + 'images', + 'thumb' +); +``` + +### Fallback URLs + +```php +$url = $model->getFallbackMediaUrl('avatar'); +``` + +### From the Media model + +```php +$media = $model->getFirstMedia('images'); + +$media->getUrl(); // original URL +$media->getUrl('thumb'); // conversion URL +$media->getPath(); // disk path +$media->getFullUrl(); // full URL with domain +$media->getTemporaryUrl(now()->addMinutes(30)); +$media->hasGeneratedConversion('thumb'); // check if conversion exists +``` + +### Filtering media + +```php +$media = $model->getMedia('images', function (Media $media) { + return $media->getCustomProperty('featured') === true; +}); + +$media = $model->getMedia('images', ['mime_type' => 'image/jpeg']); +``` + +## Custom Properties + +Store arbitrary metadata on media items: + +```php +// When adding +$model->addMedia($file) + ->withCustomProperties([ + 'alt' => 'Descriptive text', + 'credits' => 'Photographer Name', + ]) + ->toMediaCollection('images'); + +// Get/set on existing media +$media->setCustomProperty('alt', 'Updated text'); +$media->save(); + +$alt = $media->getCustomProperty('alt'); +$has = $media->hasCustomProperty('alt'); +$media->forgetCustomProperty('alt'); +$media->save(); +``` + +## Responsive Images + +Generate multiple sizes for optimal loading: + +```php +// On the FileAdder +$model->addMedia($file) + ->withResponsiveImages() + ->toMediaCollection('images'); + +// On a conversion +$this->addMediaConversion('hero') + ->fit(Fit::Max, 1200, 800) + ->withResponsiveImages(); + +// On a collection +$this->addMediaCollection('photos') + ->withResponsiveImages(); +``` + +### Using in Blade + +```blade +{{-- Renders img tag with srcset --}} +{{ $media->toHtml() }} + +{{-- With attributes --}} +{{ $media->img()->attributes(['class' => 'w-full', 'alt' => 'Photo']) }} + +{{-- Get srcset string --}} + + +{{-- Responsive conversion --}} + +``` + +### Placeholder SVG + +```php +$svg = $media->responsiveImages()->getPlaceholderSvg(); // tiny blurred base64 placeholder +``` + +## Managing Media + +### Clear a collection + +```php +$model->clearMediaCollection('images'); +``` + +### Clear except specific items + +```php +$model->clearMediaCollectionExcept('images', $mediaToKeep); +``` + +### Delete specific media + +```php +$model->deleteMedia($mediaId); +``` + +### Delete all media + +```php +$model->deleteAllMedia(); +``` + +### Delete model but keep media files + +```php +$model->deletePreservingMedia(); +``` + +### Reorder media + +```php +Media::setNewOrder([3, 1, 2]); // media IDs in desired order +``` + +### Move/copy media between models + +```php +$media->move($otherModel, 'images'); +$media->copy($otherModel, 'images'); +``` + +## Events + +```php +use Spatie\MediaLibrary\MediaCollections\Events\MediaHasBeenAddedEvent; +use Spatie\MediaLibrary\Conversions\Events\ConversionWillStartEvent; +use Spatie\MediaLibrary\Conversions\Events\ConversionHasBeenCompletedEvent; +use Spatie\MediaLibrary\MediaCollections\Events\CollectionHasBeenClearedEvent; +``` + +Listen to these events to hook into the media lifecycle: +```php +Event::listen(MediaHasBeenAddedEvent::class, function ($event) { + $event->media; // the added Media model +}); + +Event::listen(ConversionHasBeenCompletedEvent::class, function ($event) { + $event->media; + $event->conversion; +}); +``` + +## Configuration + +Key `config/media-library.php` options: + +```php +return [ + 'disk_name' => 'public', // default disk + 'max_file_size' => 1024 * 1024 * 10, // 10MB + 'queue_connection_name' => '', // queue connection + 'queue_name' => '', // queue name + 'queue_conversions_by_default' => true, // queue conversions + 'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class, + 'file_namer' => Spatie\MediaLibrary\Support\FileNamer\DefaultFileNamer::class, + 'path_generator' => Spatie\MediaLibrary\Support\PathGenerator\DefaultPathGenerator::class, + 'url_generator' => Spatie\MediaLibrary\Support\UrlGenerator\DefaultUrlGenerator::class, + 'image_driver' => 'gd', // 'gd', 'imagick', or 'vips' + 'image_optimizers' => [/* optimizer config */], + 'version_urls' => true, // cache busting + 'default_loading_attribute_value' => null, // 'lazy' for lazy loading +]; +``` + +### Custom Path Generator + +```php +use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator; + +class CustomPathGenerator implements PathGenerator +{ + public function getPath(Media $media): string + { + return md5($media->id) . '/'; + } + + public function getPathForConversions(Media $media): string + { + return $this->getPath($media) . 'conversions/'; + } + + public function getPathForResponsiveImages(Media $media): string + { + return $this->getPath($media) . 'responsive/'; + } +} +``` + +### Custom File Namer + +```php +use Spatie\MediaLibrary\Support\FileNamer\FileNamer; + +class CustomFileNamer extends FileNamer +{ + public function originalFileName(string $fileName): string + { + return Str::slug(pathinfo($fileName, PATHINFO_FILENAME)); + } + + public function conversionFileName(string $fileName, Conversion $conversion): string + { + return $this->originalFileName($fileName) . '-' . $conversion->getName(); + } + + public function responsiveFileName(string $fileName): string + { + return pathinfo($fileName, PATHINFO_FILENAME); + } +} +``` + +### Custom Media Model + +```php +use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia; + +class Media extends BaseMedia +{ + // Add custom methods, scopes, or override behavior +} +``` + +Register in config: `'media_model' => App\Models\Media::class` + +## Downloading Media + +### Single file + +```php +return $media->toResponse($request); // download +return $media->toInlineResponse($request); // display inline +return $media->stream(); // stream +``` + +### ZIP download of collection + +```php +use Spatie\MediaLibrary\Support\MediaStream; + +return MediaStream::create('photos.zip') + ->addMedia($model->getMedia('images')); +``` + +## Using with API Resources + +```php +class PostResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'image' => $this->getFirstMediaUrl('images'), + 'thumb' => $this->getFirstMediaUrl('images', 'thumb'), + 'media' => $this->getMedia('images')->map(function ($media) { + return [ + 'id' => $media->id, + 'url' => $media->getUrl(), + 'thumb' => $media->getUrl('thumb'), + 'name' => $media->name, + 'size' => $media->size, + 'type' => $media->mime_type, + ]; + }), + ]; + } +} +``` \ No newline at end of file diff --git a/.agents/skills/upgrade-laravel-v13/SKILL.md b/.agents/skills/upgrade-laravel-v13/SKILL.md new file mode 100644 index 000000000..c7aaa1582 --- /dev/null +++ b/.agents/skills/upgrade-laravel-v13/SKILL.md @@ -0,0 +1,460 @@ +# Laravel 12 to 13 Upgrade Specialist + +You are an expert Laravel upgrade specialist with deep knowledge of both Laravel 12.x and 13.0. Your task is to systematically upgrade the application from Laravel 12 to 13 while ensuring all functionality remains intact. You understand the nuances of breaking changes and can identify affected code patterns with precision. + +## Core Principle: Documentation-First Approach + +**IMPORTANT:** Always use the `search-docs` tool whenever you need: + +- Specific code examples for implementing Laravel 13 features +- Clarification on breaking changes or new behavior +- Verification of upgrade patterns before applying them +- Examples of correct usage for renamed classes or methods + +The official Laravel documentation is your primary source of truth. Consult it before making assumptions or implementing changes. + +## Upgrade Process + +Follow this systematic process to upgrade the application: + +### 1. Assess Current State + +Before making any changes: + +- Check `composer.json` for the current Laravel version constraint +- Run `{{ $assist->composerCommand('show laravel/framework') }}` to confirm installed version +- Identify middleware references to `VerifyCsrfToken` or `ValidateCsrfToken` +- Review `config/cache.php` for serialization settings +- Review `config/session.php` for cookie name configuration + +### 2. Create Safety Net + +- Ensure you're working on a dedicated branch +- Run the existing test suite to establish baseline +- Note any custom cache store implementations or queue driver implementations + +### 3. Analyze Codebase for Breaking Changes + +Search the codebase for patterns affected by v13 changes: + +**High Priority Searches:** + +- `VerifyCsrfToken` or `ValidateCsrfToken` — Must rename to `PreventRequestForgery` +- `composer.json` — Dependency version constraints to update +- `phpunit.xml` or `pest` config — Test framework version compatibility + +**Medium Priority Searches:** + +- `config/cache.php` — Check for `serializable_classes` configuration +- Code that stores PHP objects in cache — May need explicit class allow-lists + +**Low Priority Searches:** + +- `$event->exceptionOccurred` — Renamed to `$event->exception` in `JobAttempted` +- `$event->connection` on `QueueBusy` — Renamed to `$connectionName` +- `pagination::default` or `pagination::simple-default` — View names changed +- `Container::call` with nullable class defaults — Behavior changed +- Manager `extend` callbacks using `$this` — Binding changed +- Custom `Str` factories in tests — Now reset between tests + +### 4. Apply Changes Systematically + +For each category of changes: + +1. **Search** for affected patterns using grep/search tools +2. **Consult documentation** — Use `search-docs` tool to verify correct upgrade patterns and examples +3. **List** all files that need modification +4. **Apply** the fix consistently across all occurrences +5. **Verify** each change doesn't break functionality + +### 5. Update Dependencies + +After code changes are complete: + +```bash +{{ $assist->composerCommand('require laravel/framework:^13.0 --with-all-dependencies') }} +``` + +### 6. Test and Verify + +- Run the full test suite +- Verify CSRF protection still works correctly +- Check cache read/write operations +- Test any queue listeners that reference event properties + +## Execution Strategy + +When upgrading, maximize efficiency by: + +- **Batch similar changes** — Group all CSRF middleware renames, then all config updates, etc. +- **Use parallel agents** for independent file modifications +- **Prioritize high-impact changes** that could cause immediate failures +- **Test incrementally** — Verify after each category of changes + +# Upgrading from Laravel 12.x to 13.0 + +> [!NOTE] +> We attempt to document every possible breaking change. Since some of these breaking changes are in obscure parts of the framework only a portion of these changes may actually affect your application. + +## Updating Dependencies + +**Likelihood Of Impact: High** + +Update the following dependencies in your application's `composer.json` file: + +@boostsnippet('Dependency Updates', 'json') +{ +"require": { +"laravel/framework": "^13.0" +}, +"require-dev": { +"laravel/tinker": "^3.0", +"phpunit/phpunit": "^12.0", +"pestphp/pest": "^4.0" +} +} +@endboostsnippet + +Run the update: + +```bash +{{ $assist->composerCommand('update') }} +``` + +## Updating the Laravel Installer + +If you use the Laravel installer CLI tool, update it for Laravel 13.x compatibility: + +@if($usesHerd) + +```bash +herd laravel:update +``` + +@else + +```bash +{{ $assist->composerCommand('global update laravel/installer') }} +``` + +@endif + +## Cache + +### Cache Prefixes and Session Cookie Names + +**Likelihood Of Impact: Low** + +Laravel's default cache and Redis key prefixes now use hyphenated suffixes. In addition, the default session cookie name now uses `Str::snake(...)` for the application name. + +In most applications, this change will not apply because application-level configuration files already define these values. This primarily affects applications that rely on framework-level fallback configuration when corresponding application config values are not present. + +If your application relies on these generated defaults, cache keys and session cookie names may change after upgrading: + +@boostsnippet('Cache Prefix Changes', 'php') +// Laravel <= 12.x +Str::slug((string) env('APP*NAME', 'laravel'), '*').'_cache_'; +Str::slug((string) env('APP*NAME', 'laravel'), '*').'_database_'; +Str::slug((string) env('APP*NAME', 'laravel'), '*').'\_session'; + +// Laravel >= 13.x +Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'; +Str::slug((string) env('APP_NAME', 'laravel')).'-database-'; +Str::snake((string) env('APP_NAME', 'laravel')).'\_session'; +@endboostsnippet + +To retain previous behavior, explicitly configure `CACHE_PREFIX`, `REDIS_PREFIX`, and `SESSION_COOKIE` in your environment. + +### `Store` and `Repository` Contracts: `touch` + +**Likelihood Of Impact: Very Low** + +The cache contracts now include a `touch` method for extending item TTLs. If you maintain custom cache store implementations, you should add this method: + +@boostsnippet('Cache Store Touch', 'php') +// Illuminate\Contracts\Cache\Store +public function touch($key, $seconds); +@endboostsnippet + +### Cache `serializable_classes` Configuration + +**Likelihood Of Impact: Medium** + +The default application `cache` configuration now includes a `serializable_classes` option set to `false`. This hardens cache unserialization behavior to help prevent PHP deserialization gadget chain attacks if your application's `APP_KEY` is leaked. If your application intentionally stores PHP objects in cache, you should explicitly list the classes that may be unserialized: + +@boostsnippet('Cache Serializable Classes', 'php') +'serializable_classes' => [ +App\Data\CachedDashboardStats::class, +App\Support\CachedPricingSnapshot::class, +], +@endboostsnippet + +If your application previously relied on unserializing arbitrary cached objects, you will need to migrate that usage to explicit class allow-lists or to non-object cache payloads (such as arrays). + +## Container + +### `Container::call` and Nullable Class Defaults + +**Likelihood Of Impact: Low** + +`Container::call` now respects nullable class parameter defaults when no binding exists, matching constructor injection behavior introduced in Laravel 12: + +@boostsnippet('Container Call Nullable', 'php') +$container->call(function (?Carbon $date = null) { +return $date; +}); + +// Laravel <= 12.x: Carbon instance +// Laravel >= 13.x: null +@endboostsnippet + +If your method-call injection logic depended on the previous behavior, you may need to update it. + +## Contracts + +### `Dispatcher` Contract: `dispatchAfterResponse` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Bus\Dispatcher` contract now includes the `dispatchAfterResponse($command, $handler = null)` method. + +If you maintain a custom dispatcher implementation, add this method to your class. + +### `ResponseFactory` Contract: `eventStream` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Routing\ResponseFactory` contract now includes an `eventStream` signature. + +If you maintain a custom implementation of this contract, you should add this method. + +### `MustVerifyEmail` Contract: `markEmailAsUnverified` + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Auth\MustVerifyEmail` contract now includes `markEmailAsUnverified()`. + +If you provide a custom implementation of this contract, add this method to remain compatible. + +## Database + +### MySQL `DELETE` Queries With `JOIN`, `ORDER BY`, and `LIMIT` + +**Likelihood Of Impact: Low** + +Laravel now compiles full `DELETE ... JOIN` queries including `ORDER BY` and `LIMIT` for MySQL grammar. + +In previous versions, `ORDER BY` / `LIMIT` clauses could be silently ignored on joined deletes. In Laravel 13, these clauses are included in the generated SQL. As a result, database engines that do not support this syntax (such as standard MySQL / MariaDB variants) may now throw a `QueryException` instead of executing an unbounded delete. + +## Eloquent + +### Model Booting and Nested Instantiation + +**Likelihood Of Impact: Very Low** + +Creating a new model instance while that model is still booting is now disallowed and throws a `LogicException`. + +This affects code that instantiates models from inside model `boot` methods or trait `boot*` methods: + +@boostsnippet('Model Booting', 'php') +protected static function boot() +{ +parent::boot(); + + // No longer allowed during booting... + (new static())->getTable(); + +} +@endboostsnippet + +Move this logic outside the boot cycle to avoid nested booting. + +### Polymorphic Pivot Table Name Generation + +**Likelihood Of Impact: Low** + +When table names are inferred for polymorphic pivot models using custom pivot model classes, Laravel now generates pluralized names. + +If your application depended on the previous singular inferred names for morph pivot tables and used custom pivot classes, you should explicitly define the table name on your pivot model. + +### Collection Model Serialization Restores Eager-Loaded Relations + +**Likelihood Of Impact: Low** + +When Eloquent model collections are serialized and restored (such as in queued jobs), eager-loaded relations are now restored for the collection's models. + +If your code depended on relations not being present after deserialization, you may need to adjust that logic. + +## HTTP Client + +### HTTP Client `Response::throw` and `throwIf` Signatures + +**Likelihood Of Impact: Very Low** + +The HTTP client response methods now declare their callback parameters in the method signatures: + +@boostsnippet('HTTP Client Throw Signatures', 'php') +public function throw($callback = null); +public function throwIf($condition, $callback = null); +@endboostsnippet + +If you override these methods in custom response classes, ensure your method signatures are compatible. + +## Notifications + +### Default Password Reset Subject + +**Likelihood Of Impact: Very Low** + +Laravel's default password reset mail subject has changed: + +@boostsnippet('Password Reset Subject', 'text') +// Laravel <= 12.x +Reset Password Notification + +// Laravel >= 13.x +Reset your password +@endboostsnippet + +If your tests, assertions, or translation overrides depend on the previous default string, update them accordingly. + +### Queued Notifications and Missing Models + +**Likelihood Of Impact: Very Low** + +Queued notifications now respect the `#[DeleteWhenMissingModels]` attribute and `$deleteWhenMissingModels` property defined on the notification class. + +In previous versions, missing models could still cause queued notification jobs to fail in cases where you expected them to be deleted. + +## Queue + +### `JobAttempted` Event Exception Payload + +**Likelihood Of Impact: Low** + +The `Illuminate\Queue\Events\JobAttempted` event now exposes the exception object (or `null`) via `$exception`, replacing the previous boolean `$exceptionOccurred` property: + +@boostsnippet('JobAttempted Event', 'php') +// Laravel <= 12.x +$event->exceptionOccurred; + +// Laravel >= 13.x +$event->exception; +@endboostsnippet + +If you listen for this event, update your listener code accordingly. + +### `QueueBusy` Event Property Rename + +**Likelihood Of Impact: Low** + +The `Illuminate\Queue\Events\QueueBusy` event property `$connection` has been renamed to `$connectionName` for consistency with other queue events. + +If your listeners reference `$connection`, update them to `$connectionName`. + +### `Queue` Contract Method Additions + +**Likelihood Of Impact: Very Low** + +The `Illuminate\Contracts\Queue\Queue` contract now includes queue size inspection methods that were previously only declared in docblocks. + +If you maintain custom queue driver implementations of this contract, add implementations for: + +- `pendingSize` +- `delayedSize` +- `reservedSize` +- `creationTimeOfOldestPendingJob` + +## Routing + +### Domain Route Registration Precedence + +**Likelihood Of Impact: Low** + +Routes with an explicit domain are now prioritized before non-domain routes in route matching. + +This allows catch-all subdomain routes to behave consistently even when non-domain routes are registered earlier. If your application relied on previous registration precedence between domain and non-domain routes, review route matching behavior. + +## Scheduling + +### `withScheduling` Registration Timing + +**Likelihood Of Impact: Very Low** + +Schedules registered via `ApplicationBuilder::withScheduling()` are now deferred until `Schedule` is resolved. + +If your application relied on immediate schedule registration timing during bootstrap, you may need to adjust that logic. + +## Security + +### Request Forgery Protection + +**Likelihood Of Impact: High** + +Laravel's CSRF middleware has been renamed from `VerifyCsrfToken` to `PreventRequestForgery`, and now includes request-origin verification using the `Sec-Fetch-Site` header. + +`VerifyCsrfToken` and `ValidateCsrfToken` remain as deprecated aliases, but direct references should be updated to `PreventRequestForgery`, especially when excluding middleware in tests or route definitions: + +@boostsnippet('CSRF Middleware Rename', 'php') +use Illuminate\Foundation\Http\Middleware\PreventRequestForgery; +use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken; + +// Laravel <= 12.x +->withoutMiddleware([VerifyCsrfToken::class]); + +// Laravel >= 13.x +->withoutMiddleware([PreventRequestForgery::class]); +@endboostsnippet + +The middleware configuration API now also provides `preventRequestForgery(...)`. + +## Support + +### Manager `extend` Callback Binding + +**Likelihood Of Impact: Low** + +Custom driver closures registered via manager `extend` methods are now bound to the manager instance. + +If you previously relied on another bound object (such as a service provider instance) as `$this` inside these callbacks, you should move those values into closure captures using `use (...)`. + +### `Str` Factories Reset Between Tests + +**Likelihood Of Impact: Low** + +Laravel now resets custom `Str` factories during test teardown. + +If your tests depended on custom UUID / ULID / random string factories persisting between test methods, you should set them in each relevant test or setup hook. + +### `Js::from` Uses Unescaped Unicode By Default + +**Likelihood Of Impact: Very Low** + +`Illuminate\Support\Js::from` now uses `JSON_UNESCAPED_UNICODE` by default. + +If your tests or frontend output comparisons depended on escaped Unicode sequences (for example `\u00e8`), update your expectations. + +## Views + +### Pagination Bootstrap View Names + +**Likelihood Of Impact: Low** + +The internal pagination view names for Bootstrap 3 defaults are now explicit: + +@boostsnippet('Pagination Views', 'text') +// Laravel <= 12.x +pagination::default +pagination::simple-default + +// Laravel >= 13.x +pagination::bootstrap-3 +pagination::simple-bootstrap-3 +@endboostsnippet + +## Getting help + +If you encounter issues during the upgrade: + +- Check the [upgrade guide](https://laravel.com/docs/13.x/upgrade) for the latest details +- Review the [GitHub comparison](https://github.com/laravel/laravel/compare/12.x...13.x) for skeleton changes diff --git a/.codex/config.toml b/.codex/config.toml index 217017bce..ef8aa1a9b 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -1,7 +1,21 @@ [mcp_servers.laravel-boost] command = "php" -args = ["artisan", "boost:mcp"] +args = [ + "artisan", + "boost:mcp", +] [mcp_servers.nightwatch] command = "npx" -args = ["-y", "mcp-remote", "https://nightwatch.laravel.com/mcp"] +args = [ + "-y", + "mcp-remote", + "https://nightwatch.laravel.com/mcp", +] + +[mcp_servers.herd] +command = "php" +args = ["/Applications/Herd.app/Contents/Resources/herd-mcp.phar"] + +[mcp_servers.herd.env] +SITE_PATH = "/Users/paulocastellano/Code/trypost" diff --git a/CLAUDE.md b/CLAUDE.md index 0279f6067..7ab29072e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -261,7 +261,7 @@ Standing constraints: - This project uses Laravel Wayfinder for type-safe frontend routing. - ALWAYS use Wayfinder-generated route helpers in Vue pages (e.g. `register()`, `login()`, `dashboard()`). NEVER hardcode URL strings like `href="/register"`. -- After creating or modifying PHP routes/controllers, run `php artisan wayfinder:generate` to regenerate the TypeScript route helpers. +- After creating or modifying PHP routes/controllers, run `php artisan wayfinder:generate --with-form` to regenerate the TypeScript route helpers. The `--with-form` flag is required: `vite.config.ts` sets `formVariants: true`, and regenerating without it drops the form variants and breaks `vue-tsc` across every file that imports one. - Import routes from `@/routes/...` (e.g. `import { store } from '@/routes/login'`). ## Pagination diff --git a/app/Actions/Onboarding/FetchLatestSocialPost.php b/app/Actions/Onboarding/FetchLatestSocialPost.php new file mode 100644 index 000000000..dea3c48c7 --- /dev/null +++ b/app/Actions/Onboarding/FetchLatestSocialPost.php @@ -0,0 +1,564 @@ +, each_views: int, extra_views: int}}|null + */ + public function handle(SocialAccount $account): ?array + { + if ($account->status !== Status::Connected) { + return null; + } + + if (! $account->platform->supportsImpressionAnalytics()) { + return null; + } + + try { + $post = match ($account->platform) { + Platform::Instagram, Platform::InstagramFacebook => $this->instagram($account), + Platform::Facebook => $this->facebook($account), + Platform::X => $this->x($account), + Platform::Threads => $this->threads($account), + Platform::TikTok => $this->tiktok($account), + Platform::YouTube => $this->youtube($account), + Platform::Pinterest => $this->pinterest($account), + Platform::LinkedInPage => $this->linkedinPage($account), + default => null, + }; + + return $post === null ? null : $this->withReach($account, $post); + } catch (Throwable $e) { + report($e); + + return null; + } + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function instagram(SocialAccount $account): ?array + { + $base = $account->platform->instagramGraphBaseUrl(); + $response = $this->http() + ->get("{$base}/{$account->platform_user_id}/media", [ + 'fields' => 'id,caption,media_type,media_url,thumbnail_url,permalink,timestamp', + 'limit' => 1, + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + $this->logFailure('Instagram', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'data.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + $mediaUrl = data_get($item, 'media_type') === 'VIDEO' + ? data_get($item, 'thumbnail_url') + : data_get($item, 'media_url'); + + return $this->post( + $id, + data_get($item, 'caption'), + is_string($mediaUrl) ? $mediaUrl : null, + data_get($item, 'permalink'), + data_get($item, 'timestamp'), + $this->graphInsights($account->platform->instagramGraphBaseUrl(), $id, $account->access_token, ['views', 'reach']), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function facebook(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.facebook.graph_api'); + $response = $this->http() + ->get("{$base}/{$account->platform_user_id}/posts", [ + 'fields' => 'id,message,full_picture,permalink_url,created_time', + 'limit' => 1, + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + $this->logFailure('Facebook', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'data.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + + return $this->post( + $id, + data_get($item, 'message'), + data_get($item, 'full_picture'), + data_get($item, 'permalink_url'), + data_get($item, 'created_time'), + $this->graphInsights((string) config('trypost.platforms.facebook.graph_api'), $id, $account->access_token, ['post_impressions']), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function x(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.x.api'); + $response = $this->http() + ->withToken($account->access_token) + ->get("{$base}/users/{$account->platform_user_id}/tweets", [ + 'max_results' => 5, + 'tweet.fields' => 'created_at,text,attachments,public_metrics', + 'expansions' => 'attachments.media_keys', + 'media.fields' => 'url,preview_image_url', + ]); + + if ($response->failed()) { + $this->logFailure('X', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'data.0'); + + if (! is_array($item)) { + return null; + } + + $mediaKey = data_get($item, 'attachments.media_keys.0'); + $mediaUrl = null; + + if (is_string($mediaKey)) { + $media = collect(data_get($response->json(), 'includes.media', [])) + ->first(fn (mixed $row): bool => is_array($row) && data_get($row, 'media_key') === $mediaKey); + + $mediaUrl = is_array($media) + ? (data_get($media, 'preview_image_url') ?? data_get($media, 'url')) + : null; + } + + $id = (string) data_get($item, 'id'); + + $impressions = data_get($item, 'public_metrics.impression_count'); + + return $this->post( + $id, + data_get($item, 'text'), + is_string($mediaUrl) ? $mediaUrl : null, + "https://x.com/i/web/status/{$id}", + data_get($item, 'created_at'), + is_numeric($impressions) ? (int) $impressions : null, + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function threads(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.threads.graph_api'); + $response = $this->http() + ->get("{$base}/{$account->platform_user_id}/threads", [ + 'fields' => 'id,text,media_type,permalink,timestamp,media_url,thumbnail_url', + 'limit' => 1, + 'access_token' => $account->access_token, + ]); + + if ($response->failed()) { + $this->logFailure('Threads', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'data.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + $mediaUrl = data_get($item, 'thumbnail_url') ?? data_get($item, 'media_url'); + + return $this->post( + $id, + data_get($item, 'text'), + is_string($mediaUrl) ? $mediaUrl : null, + data_get($item, 'permalink'), + data_get($item, 'timestamp'), + $this->graphInsights((string) config('trypost.platforms.threads.graph_api'), $id, $account->access_token, ['views']), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function tiktok(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.tiktok.api'); + $response = $this->http() + ->asJson() + ->withToken($account->access_token) + ->post("{$base}/video/list/?fields=id,title,cover_image_url,share_url,create_time", [ + 'max_count' => 1, + ]); + + if ($response->failed()) { + $this->logFailure('TikTok', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'data.videos.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + $created = data_get($item, 'create_time'); + + return $this->post( + $id, + data_get($item, 'title'), + data_get($item, 'cover_image_url'), + data_get($item, 'share_url'), + is_numeric($created) ? now()->setTimestamp((int) $created)->toIso8601String() : null, + $this->tiktokViews($account, $id), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function youtube(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.youtube.data_api'); + $response = $this->http() + ->withToken($account->access_token) + ->get("{$base}/search", [ + 'part' => 'snippet', + 'forMine' => 'true', + 'type' => 'video', + 'order' => 'date', + 'maxResults' => 1, + ]); + + if ($response->failed()) { + $this->logFailure('YouTube', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'items.0'); + + if (! is_array($item)) { + return null; + } + + $videoId = (string) data_get($item, 'id.videoId'); + + if ($videoId === '') { + return null; + } + + return $this->post( + $videoId, + data_get($item, 'snippet.title'), + data_get($item, 'snippet.thumbnails.high.url') ?? data_get($item, 'snippet.thumbnails.default.url'), + "https://www.youtube.com/watch?v={$videoId}", + data_get($item, 'snippet.publishedAt'), + $this->youtubeViews($account, $videoId), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function pinterest(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.pinterest.api'); + $response = $this->http() + ->withToken($account->access_token) + ->get("{$base}/pins", [ + 'page_size' => 1, + ]); + + if ($response->failed()) { + $this->logFailure('Pinterest', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'items.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + + return $this->post( + $id, + data_get($item, 'description') ?? data_get($item, 'title'), + data_get($item, 'media.images.400x300.url') ?? data_get($item, 'media.images.150x150.url'), + null, + data_get($item, 'created_at'), + $this->pinterestImpressions($account, $id), + ); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function linkedinPage(SocialAccount $account): ?array + { + $base = (string) config('trypost.platforms.linkedin-page.api').'/rest'; + $author = rawurlencode('urn:li:organization:'.$account->platform_user_id); + $response = $this->http() + ->withToken($account->access_token) + ->withHeaders([ + 'Linkedin-Version' => '202601', + 'X-Restli-Protocol-Version' => '2.0.0', + ]) + ->get("{$base}/posts?q=author&author={$author}&count=1&sortBy=LAST_MODIFIED"); + + if ($response->failed()) { + $this->logFailure('LinkedIn Page', $response->body()); + + return null; + } + + $item = data_get($response->json(), 'elements.0'); + + if (! is_array($item)) { + return null; + } + + $id = (string) data_get($item, 'id'); + $createdAt = data_get($item, 'createdAt'); + + return $this->post( + $id, + data_get($item, 'commentary'), + null, + $id !== '' ? "https://www.linkedin.com/feed/update/{$id}" : null, + is_numeric($createdAt) + ? now()->setTimestamp((int) ((int) $createdAt / 1000))->toIso8601String() + : null, + ); + } + + private function http(): PendingRequest + { + return Http::timeout(8)->connectTimeout(3); + } + + /** + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null}|null + */ + private function post( + string $id, + mixed $caption, + mixed $mediaUrl, + mixed $permalink, + mixed $publishedAt, + ?int $impressions = null, + ): ?array { + if ($id === '') { + return null; + } + + return [ + 'id' => $id, + 'caption' => is_string($caption) && $caption !== '' ? $caption : null, + 'media_url' => is_string($mediaUrl) && $mediaUrl !== '' ? $mediaUrl : null, + 'permalink' => is_string($permalink) && $permalink !== '' ? $permalink : null, + 'published_at' => is_string($publishedAt) && $publishedAt !== '' ? $publishedAt : null, + 'impressions' => $impressions, + ]; + } + + /** + * @param array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null} $post + * @return array{id: string, caption: string|null, media_url: string|null, permalink: string|null, published_at: string|null, impressions: int|null, reach: array{network: string, network_value: string, others: list, each_views: int, extra_views: int}} + */ + private function withReach(SocialAccount $account, array $post): array + { + $eachViews = max(self::MISSED_VIEWS_PER_NETWORK, $post['impressions'] ?? 0); + $others = $this->missedNetworks($account->platform, $eachViews); + + return [ + ...$post, + 'reach' => [ + 'network' => $this->networkLabel($account->platform), + 'network_value' => $account->platform->network(), + 'others' => $others, + 'each_views' => $eachViews, + 'extra_views' => $eachViews * count($others), + ], + ]; + } + + /** + * @return list + */ + private function missedNetworks(Platform $platform, int $eachViews): array + { + return collect($platform->onboardingReachComparisons()) + ->filter(fn (Platform $candidate): bool => $candidate->isConnectable()) + ->reject(fn (Platform $candidate): bool => $candidate->network() === $platform->network()) + ->take(2) + ->map(fn (Platform $candidate): array => [ + 'value' => $candidate->value, + 'label' => $this->networkLabel($candidate), + 'views' => $eachViews, + ]) + ->values() + ->all(); + } + + private function networkLabel(Platform $platform): string + { + return match ($platform) { + Platform::InstagramFacebook => Platform::Instagram->label(), + Platform::YouTube => 'YouTube', + Platform::Facebook => 'Facebook', + default => $platform->label(), + }; + } + + /** + * @param list $metrics + */ + private function graphInsights(string $base, string $id, ?string $token, array $metrics): ?int + { + if ($id === '' || $token === null || $token === '') { + return null; + } + + foreach ($metrics as $metric) { + $response = $this->http()->get("{$base}/{$id}/insights", [ + 'metric' => $metric, + 'access_token' => $token, + ]); + + if ($response->failed()) { + continue; + } + + $value = data_get($response->json(), 'data.0.values.0.value'); + + if (is_numeric($value)) { + return (int) $value; + } + } + + return null; + } + + private function tiktokViews(SocialAccount $account, string $videoId): ?int + { + if ($videoId === '') { + return null; + } + + $base = (string) config('trypost.platforms.tiktok.api'); + $response = $this->http() + ->asJson() + ->withToken($account->access_token) + ->post("{$base}/video/query/?fields=id,view_count", [ + 'filters' => ['video_ids' => [$videoId]], + ]); + + if ($response->failed()) { + return null; + } + + $views = data_get($response->json(), 'data.videos.0.view_count'); + + return is_numeric($views) ? (int) $views : null; + } + + private function youtubeViews(SocialAccount $account, string $videoId): ?int + { + $base = (string) config('trypost.platforms.youtube.data_api'); + $response = $this->http() + ->withToken($account->access_token) + ->get("{$base}/videos", [ + 'part' => 'statistics', + 'id' => $videoId, + ]); + + if ($response->failed()) { + return null; + } + + $views = data_get($response->json(), 'items.0.statistics.viewCount'); + + return is_numeric($views) ? (int) $views : null; + } + + private function pinterestImpressions(SocialAccount $account, string $pinId): ?int + { + if ($pinId === '') { + return null; + } + + $base = (string) config('trypost.platforms.pinterest.api'); + $response = $this->http() + ->withToken($account->access_token) + ->get("{$base}/pins/{$pinId}/analytics", [ + 'start_date' => now()->subDays(90)->format('Y-m-d'), + 'end_date' => now()->format('Y-m-d'), + 'metric_types' => 'IMPRESSION', + ]); + + if ($response->failed()) { + return null; + } + + $impressions = data_get($response->json(), 'all.summary_metrics.IMPRESSION'); + + return is_numeric($impressions) ? (int) $impressions : null; + } + + private function logFailure(string $platform, string $body): void + { + Log::warning("{$platform} latest post fetch failed", [ + 'body' => mb_substr((string) TokenRedactor::redact($body), 0, 500), + ]); + } +} diff --git a/app/Actions/Post/CreatePost.php b/app/Actions/Post/CreatePost.php index a0f439e89..e00325379 100644 --- a/app/Actions/Post/CreatePost.php +++ b/app/Actions/Post/CreatePost.php @@ -29,12 +29,17 @@ class CreatePost * `created_via` records which entry point created the post (web, mcp, * api, or automation). Analytical only — null when omitted. * + * `creation_id` links the post back to the AI generation that produced + * it (see `App\Jobs\Ai\StreamPostCreation`). Null when the post was not + * AI-generated. + * * @param array{ * content?: ?string, * media?: array, * date?: ?string, * scheduled_at?: ?string, * created_via?: ?CreatedVia, + * creation_id?: ?string, * platforms?: array}>, * label_ids?: array * } $data @@ -50,6 +55,7 @@ public static function execute(Workspace $workspace, User $user, array $data): P 'media' => data_get($data, 'media', []), 'status' => PostStatus::Draft, 'created_via' => data_get($data, 'created_via'), + 'creation_id' => data_get($data, 'creation_id'), 'scheduled_at' => $scheduledAt, ]); diff --git a/app/Ai/Agents/ConversationTitleGenerator.php b/app/Ai/Agents/ConversationTitleGenerator.php new file mode 100644 index 000000000..e19cfae00 --- /dev/null +++ b/app/Ai/Agents/ConversationTitleGenerator.php @@ -0,0 +1,25 @@ +render(); + } +} diff --git a/app/Ai/Agents/WorkspaceConversationAgent.php b/app/Ai/Agents/WorkspaceConversationAgent.php new file mode 100644 index 000000000..54b66cb5a --- /dev/null +++ b/app/Ai/Agents/WorkspaceConversationAgent.php @@ -0,0 +1,81 @@ + $this->workspace->name ?? '', + 'brand_description' => $this->workspace->brand_description ?? '', + 'brand_voice_traits' => $this->workspace->brand_voice_traits ?? [], + 'content_language' => $this->workspace->content_language, + 'connected_platforms' => $this->workspace->socialAccounts() + ->get() + ->map(fn ($account): string => $account->platform->value) + ->unique() + ->values() + ->all(), + ])->render(); + } + + /** + * @return iterable + */ + public function tools(): iterable + { + return [ + new ListPostsTool($this->workspace, $this->user), + new GetPostTool($this->workspace, $this->user), + new GetPostMetricsTool($this->workspace, $this->user), + new StartPostGenerationTool($this->workspace, $this->user), + new GeneratePostTool($this->workspace, $this->user), + new CreatePostTool($this->workspace, $this->user), + new UpdatePostTool($this->workspace, $this->user), + new SchedulePostTool($this->workspace, $this->user), + new PublishPostTool($this->workspace, $this->user), + new DeletePostTool($this->workspace, $this->user), + ]; + } +} diff --git a/app/Ai/Tools/Post/CreatePostTool.php b/app/Ai/Tools/Post/CreatePostTool.php new file mode 100644 index 000000000..3e6d3962f --- /dev/null +++ b/app/Ai/Tools/Post/CreatePostTool.php @@ -0,0 +1,49 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'content' => $schema->string()->required()->description('The text content of the new draft post.'), + ]; + } + + protected function run(Request $request): string + { + $post = CreatePost::execute($this->workspace, $this->user, [ + 'content' => $request->string('content')->value(), + 'created_via' => CreatedVia::Chat, + ]); + + return $this->json([ + 'data' => (new ChatPostResource($post->load('postPlatforms.socialAccount')))->withFullContent()->resolve(), + ]); + } +} diff --git a/app/Ai/Tools/Post/DeletePostTool.php b/app/Ai/Tools/Post/DeletePostTool.php new file mode 100644 index 000000000..a3cdc4c7e --- /dev/null +++ b/app/Ai/Tools/Post/DeletePostTool.php @@ -0,0 +1,87 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to delete.'), + ]; + } + + protected function needsApproval(Request $request): Approval|bool + { + if ($this->writeDenied()) { + return false; + } + + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null || $post->status === Status::Draft || PostStatusRules::blocksDeletion($post)) { + return false; + } + + return Approval::required(__('chat.approvals.delete_scheduled')); + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null) { + return $this->error(__('chat.tools.post_not_found')); + } + + if (PostStatusRules::blocksDeletion($post)) { + return $this->error(__('chat.tools.delete_blocked')); + } + + DeletePost::execute($post); + + return $this->json(['data' => ['id' => $post->id, 'deleted' => true]]); + } +} diff --git a/app/Ai/Tools/Post/GeneratePostTool.php b/app/Ai/Tools/Post/GeneratePostTool.php new file mode 100644 index 000000000..14acfb103 --- /dev/null +++ b/app/Ai/Tools/Post/GeneratePostTool.php @@ -0,0 +1,342 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'prompt' => $schema->string()->required()->description('What the post should be about, in the user\'s own words. Between '.AiPromptRules::PROMPT_MIN_LENGTH.' and '.AiPromptRules::PROMPT_MAX_LENGTH.' characters.'), + 'format' => $schema->string()->required()->description('The format to generate for, taken from the formats start_post_generation returned for this workspace, e.g. "x_post" or "instagram_carousel".'), + 'style' => $schema->string()->required()->enum(app(AiTemplateRegistry::class)->keys())->description('The visual style key, taken from the styles start_post_generation returned.'), + 'social_account_id' => $schema->string()->description('The id of the connected account the post is for, taken from the chosen format\'s accounts. Required for a style whose needs_account is true.'), + 'image_count' => $schema->integer()->min(0)->max(self::MAX_IMAGE_COUNT)->description('How many images to generate. 0 for a text-only post. Defaults to 0.'), + 'date' => $schema->string()->description('Optional date the post is meant for, as Y-m-d.'), + 'apply_brand_visuals' => $schema->boolean()->description('Whether the generated images use the workspace brand palette. Defaults to true.'), + ]; + } + + protected function run(Request $request): string + { + $format = $request->string('format')->trim()->value(); + $style = $request->string('style')->trim()->value(); + $socialAccountId = $request->filled('social_account_id') + ? $request->string('social_account_id')->trim()->value() + : null; + + $error = $this->aiAccessError() ?? $this->argumentError($request); + + if ($error !== null) { + return $this->error($error); + } + + $catalog = PostGenerationCatalog::forWorkspace($this->workspace); + + $error = $this->formatError($catalog, $format) + ?? $this->styleError($style, $socialAccountId) + ?? $this->socialAccountError($catalog, $format, $socialAccountId) + ?? $this->imageCountError($format, $request->integer('image_count')); + + if ($error !== null) { + return $this->error($error); + } + + $creationId = $this->creationIdFor($request); + + StreamPostCreation::dispatch( + userId: $this->user->id, + creationId: $creationId, + workspaceId: $this->workspace->id, + format: $format, + socialAccountId: $socialAccountId, + imageCount: $request->integer('image_count'), + prompt: $request->string('prompt')->trim()->value(), + date: $request->filled('date') ? $request->string('date')->trim()->value() : null, + template: $style, + applyBrandVisuals: $request->boolean('apply_brand_visuals', true), + ); + + return $this->json([ + 'data' => [ + 'creation_id' => $creationId, + 'channel' => $this->channelFor($creationId), + ], + ]); + } + + /** + * The `useAi` gate, run before dispatching. Usage itself is recorded by + * StreamPostCreation, so nothing is metered here — doing it in both + * places would bill the account twice. + */ + private function aiAccessError(): ?string + { + $gate = Gate::forUser($this->user)->inspect('useAi', $this->workspace->account); + + return $gate->denied() ? (string) $gate->message() : null; + } + + /** + * Shape rules for the model-supplied arguments, including the shared + * prompt bounds. The framework's own messages already name the offending + * argument and the bound it broke. + */ + private function argumentError(Request $request): ?string + { + $validator = Validator::make($request->toArray(), [ + 'prompt' => AiPromptRules::generationPromptRule(), + 'social_account_id' => ['nullable', 'uuid'], + 'image_count' => ['nullable', 'integer', 'min:0', 'max:'.self::MAX_IMAGE_COUNT], + 'date' => ['nullable', 'date_format:Y-m-d'], + ], attributes: [ + 'social_account_id' => 'social_account_id', + 'image_count' => 'image_count', + ]); + + return $validator->fails() ? $validator->errors()->first() : null; + } + + /** + * The format must be one the catalog offers for THIS workspace, not merely + * a format the platform knows: a format whose platform has no connected + * account cannot produce a publishable post. + * + * @param array{ + * formats: list}>, + * styles: list, applies_brand_visuals: bool}>, + * applies_brand_visuals_default: bool, + * } $catalog + */ + private function formatError(array $catalog, string $format): ?string + { + $available = array_values(array_unique( + array_column(data_get($catalog, 'formats', []), 'value') + )); + + if ($available === []) { + return 'This workspace has no connected social accounts, so there is no format to generate a post for. Ask the user to connect an account first.'; + } + + if (in_array($format, $available, true)) { + return null; + } + + $options = implode(', ', $available); + + if ($format === '') { + return "The \"format\" argument is required. Call start_post_generation and pass one of: {$options}."; + } + + return "The format \"{$format}\" isn't available in this workspace. Call start_post_generation and pass one of: {$options}."; + } + + /** + * The style key must exist in the registry — validated here rather than by + * catching AiTemplateRegistry::find()'s InvalidArgumentException, which + * WorkspaceTool::handle() would flatten into the generic error the model + * cannot act on. + */ + private function styleError(string $style, ?string $socialAccountId): ?string + { + $registry = app(AiTemplateRegistry::class); + $keys = $registry->keys(); + $options = implode(', ', $keys); + + if ($style === '') { + return "The \"style\" argument is required. Valid styles are: {$options}."; + } + + if (! in_array($style, $keys, true)) { + return "The style \"{$style}\" doesn't exist. Valid styles are: {$options}."; + } + + if ($registry->find($style)->needsAccount() && blank($socialAccountId)) { + return "The \"{$style}\" style renders the post as the account's own card, so it needs a connected account. Pass social_account_id using one of the account ids start_post_generation returned for this format."; + } + + return null; + } + + /** + * The account must be one the catalog itself offers for the chosen format, + * which is stricter than mere workspace ownership in two ways that matter: + * the catalog lists only ACTIVE accounts, and it lists them per format, so + * an account on a platform the format cannot post to is refused here + * rather than producing a post bound to the wrong account. + * + * @param array{ + * formats: list}>, + * styles: list, applies_brand_visuals: bool}>, + * applies_brand_visuals_default: bool, + * } $catalog + */ + private function socialAccountError(array $catalog, string $format, ?string $socialAccountId): ?string + { + if ($socialAccountId === null) { + return null; + } + + $accounts = $this->accountsForFormat($catalog, $format); + + if (in_array($socialAccountId, array_column($accounts, 'id'), true)) { + return null; + } + + $options = implode(', ', array_map( + fn (array $account): string => data_get($account, 'id').' ('.self::describeAccount($account).')', + $accounts, + )); + + return "The social account \"{$socialAccountId}\" can't be used for the \"{$format}\" format — it belongs to another workspace, is disconnected, or is on a different platform. Use one of these account ids instead: {$options}."; + } + + /** + * The catalog's active accounts for one format, flattened across the + * platforms that format is compatible with. Never empty for a format that + * passed formatError(): the catalog only lists a format once at least one + * active account can post it. + * + * @param array{ + * formats: list}>, + * styles: list, applies_brand_visuals: bool}>, + * applies_brand_visuals_default: bool, + * } $catalog + * @return list + */ + private function accountsForFormat(array $catalog, string $format): array + { + $accounts = []; + + foreach (data_get($catalog, 'formats', []) as $entry) { + if (data_get($entry, 'value') !== $format) { + continue; + } + + foreach (data_get($entry, 'accounts', []) as $account) { + $accounts[data_get($account, 'id')] = $account; + } + } + + return array_values($accounts); + } + + /** + * A human-readable name for one catalog account. The display label alone + * is not enough: two Instagram connections for the same brand share it, so + * the handle is appended whenever the account has one. + * + * @param array{id: string, label: string, username: ?string, platform: string} $account + */ + private static function describeAccount(array $account): string + { + $label = (string) data_get($account, 'label'); + $username = data_get($account, 'username'); + + if (! is_string($username) || $username === '') { + return $label; + } + + return "{$label} @{$username}"; + } + + /** + * The generation's id, and with it the uniqueness lock on + * {@see StreamPostCreation} (`ShouldBeUnique`, keyed on + * `{userId}:{creationId}`). + * + * The provider's tool call id is the right key: an SDK-level retry of the + * SAME tool call carries the same id, so the second dispatch is swallowed + * by the lock instead of generating — and billing — twice, while two + * deliberate calls carry different ids and both run. A minted uuid is the + * fallback for a caller that supplies no id (a direct handle() call, or a + * provider that omits one), where nothing can be deduplicated anyway. + */ + private function creationIdFor(Request $request): string + { + return $request->toolCallId() ?? Str::uuid()->toString(); + } + + /** + * Beyond the global 0-10 bound, a format never accepts more media than the + * platform itself does, so a generation that asked for more could never be + * published. + * + * Runs only after formatError() has accepted the format, so the format is + * always one the catalog offers — every one of which resolves to a + * ContentType (the carousel pseudo-format through ResolvesContentType). + */ + private function imageCountError(string $format, int $imageCount): ?string + { + $max = self::resolveContentType($format)->maxMediaCount(); + + if ($imageCount <= $max) { + return null; + } + + return "The format \"{$format}\" accepts at most {$max} images. Call generate_post again with image_count set to {$max} or fewer."; + } + + /** + * The private channel the finished post is announced on, taken from the + * event itself so the name is never spelled out a second time. Echo + * subscribes with `private()`, which adds the prefix back. + */ + private function channelFor(string $creationId): string + { + $channel = (new PostCreationReady($this->user->id, $creationId))->broadcastOn(); + + return Str::after($channel->name, 'private-'); + } +} diff --git a/app/Ai/Tools/Post/GetPostMetricsTool.php b/app/Ai/Tools/Post/GetPostMetricsTool.php new file mode 100644 index 000000000..6cbbd08ad --- /dev/null +++ b/app/Ai/Tools/Post/GetPostMetricsTool.php @@ -0,0 +1,46 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to fetch metrics for.'), + ]; + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if (! $post) { + return $this->error(__('chat.tools.post_not_found')); + } + + return $this->json(['data' => (new ChatPostMetricsResource($post))->resolve()]); + } +} diff --git a/app/Ai/Tools/Post/GetPostTool.php b/app/Ai/Tools/Post/GetPostTool.php new file mode 100644 index 000000000..e57126070 --- /dev/null +++ b/app/Ai/Tools/Post/GetPostTool.php @@ -0,0 +1,46 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to fetch.'), + ]; + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if (! $post) { + return $this->error(__('chat.tools.post_not_found')); + } + + return $this->json(['data' => (new ChatPostResource($post))->withFullContent()->resolve()]); + } +} diff --git a/app/Ai/Tools/Post/ListPostsTool.php b/app/Ai/Tools/Post/ListPostsTool.php new file mode 100644 index 000000000..c90a1a74d --- /dev/null +++ b/app/Ai/Tools/Post/ListPostsTool.php @@ -0,0 +1,63 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'status' => $schema->string()->enum(['draft', 'scheduled', 'published', 'failed']), + 'search' => $schema->string(), + 'limit' => $schema->integer()->min(1)->max(25), + ]; + } + + protected function run(Request $request): string + { + $query = $this->workspace->posts()->with(['postPlatforms.socialAccount']); + + $query = match ($request->enum('status', Status::class)) { + Status::Draft => $query->draft(), + Status::Scheduled => $query->scheduled(), + Status::Published => $query->published(), + Status::Failed => $query->failed(), + default => $query, + }; + + $search = $request->string('search')->value(); + + if ($search !== '') { + $query->whereLike('content', "%{$search}%", caseSensitive: false); + } + + $limit = (int) $request->clamp('limit', 1, 25, 10); + + $posts = $query->latest('created_at')->limit($limit)->get(); + + return $this->json(['data' => ChatPostResource::collection($posts)->resolve()]); + } +} diff --git a/app/Ai/Tools/Post/PublishPostTool.php b/app/Ai/Tools/Post/PublishPostTool.php new file mode 100644 index 000000000..f70fe97d0 --- /dev/null +++ b/app/Ai/Tools/Post/PublishPostTool.php @@ -0,0 +1,130 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to publish.'), + ]; + } + + protected function needsApproval(Request $request): Approval|bool + { + if ($this->writeDenied()) { + return false; + } + + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null || $this->publishBlockedReason($post) !== null) { + return false; + } + + return Approval::required(__('chat.approvals.publish')); + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null) { + return $this->error(__('chat.tools.post_not_found')); + } + + if ($reason = $this->publishBlockedReason($post)) { + return $this->error($reason); + } + + $result = UpdatePost::execute($this->workspace, $post, [ + 'status' => Status::Publishing->value, + ]); + + if (data_get($result, 'action') === PostAction::Finalized) { + return $this->error(PostStatusRules::editBlockedMessage()); + } + + return $this->json([ + 'data' => (new ChatPostResource($post->fresh()->load('postPlatforms.socialAccount')))->withFullContent()->resolve(), + ]); + } + + /** + * Why this post can't be published right now, or null when it's ready. + * The single source of truth for both the approval gate and the actual + * refusal message, so the two can never drift apart. + */ + private function publishBlockedReason(Post $post): ?string + { + if (PostStatusRules::blocksEditing($post)) { + return PostStatusRules::editBlockedMessage(); + } + + if (! $post->postPlatforms()->enabled()->exists()) { + return __('chat.tools.publish_no_enabled_platforms'); + } + + try { + PostPlatformMetaRules::assertStoredPostPublishable($post); + ContentTypeCompatibleWithMedia::assertStoredPostCompatible($post); + } catch (ValidationException $e) { + return (string) $e->validator->errors()->first(); + } + + return null; + } +} diff --git a/app/Ai/Tools/Post/SchedulePostTool.php b/app/Ai/Tools/Post/SchedulePostTool.php new file mode 100644 index 000000000..21f608ca0 --- /dev/null +++ b/app/Ai/Tools/Post/SchedulePostTool.php @@ -0,0 +1,74 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to schedule.'), + 'scheduled_at' => $schema->string()->required()->description('The date and time to publish the post, in ISO 8601 format.'), + ]; + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null) { + return $this->error(__('chat.tools.post_not_found')); + } + + $scheduledAt = $request->filled('scheduled_at') ? $request->string('scheduled_at')->value() : null; + + $validator = Validator::make( + ['scheduled_at' => $scheduledAt], + ['scheduled_at' => PostStatusRules::scheduledAtRules($post, Status::Scheduled->value)], + ); + + if ($validator->fails()) { + return $this->error((string) $validator->errors()->first('scheduled_at')); + } + + $result = UpdatePost::execute($this->workspace, $post, [ + 'scheduled_at' => $scheduledAt, + 'status' => Status::Scheduled->value, + ]); + + if (data_get($result, 'action') === PostAction::Finalized) { + return $this->error(PostStatusRules::editBlockedMessage()); + } + + return $this->json([ + 'data' => (new ChatPostResource($post->fresh()->load('postPlatforms.socialAccount')))->withFullContent()->resolve(), + ]); + } +} diff --git a/app/Ai/Tools/Post/StartPostGenerationTool.php b/app/Ai/Tools/Post/StartPostGenerationTool.php new file mode 100644 index 000000000..ae06b8504 --- /dev/null +++ b/app/Ai/Tools/Post/StartPostGenerationTool.php @@ -0,0 +1,126 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'topic' => $schema->string()->description('What the user said the post should be about, in their own words, taken from the conversation — e.g. "the X launch". Required. When the user has not said what the post is about, do NOT call this tool and do NOT invent a topic: ask them in plain text what the post should be about, and call this once they answer. A post generated about a topic nobody chose wastes the generation and the user\'s credits.'), + 'format' => $schema->string()->enum(PostGenerationCatalog::formatValues())->description('The format the user named, when they named one — "an Instagram carousel" is instagram_carousel, "a tweet" is x_post. The card records it as their choice, with a link to change it, so it never asks again for something they already said. Leave this out when the user did not name a format, or named something outside this list: the card then asks, which is the right outcome. A format this workspace has no connected account for is ignored the same way.'), + 'language' => $schema->string()->enum(ContentLanguage::class)->description('The language the user is writing in, as the code from this list — "quero gerar um carrossel" is pt-BR, "generate a post" is en. Every word of the card is rendered in it, so the card speaks the same language you do. Leave this out when the conversation gives you nothing to go on; the interface language is then used.'), + ]; + } + + protected function run(Request $request): string + { + $locale = $this->resolveLocale($request); + $topic = $request->filled('topic') ? $request->string('topic')->trim()->value() : ''; + + if ($topic === '') { + return $this->error('No topic was given. Ask the user in plain text what the post should be about, then call start_post_generation again with their answer as the topic. Do not invent one.'); + } + + $catalog = PostGenerationCatalog::forWorkspace($this->workspace, $locale); + $catalog['locale'] = $locale; + $catalog['copy'] = PostGenerationCardCopy::forLocale($locale); + $catalog['topic'] = $topic; + $catalog['format'] = $this->resolveFormat($request, $catalog); + + return $this->json(['data' => $catalog]); + } + + /** + * The conversation's own locale, or the application's when the model named + * none or named one this app does not ship. Matched case-insensitively + * because a model that returns "PT-BR" means the same locale we do, and a + * fallback to English there would be a silent regression rather than a + * safe default. + */ + private function resolveLocale(Request $request): string + { + $language = $request->filled('language') ? $request->string('language')->trim()->value() : ''; + + foreach (ContentLanguage::cases() as $case) { + if (strcasecmp($case->value, $language) === 0) { + return $case->value; + } + } + + return App::getLocale(); + } + + /** + * The format the model read from the conversation, kept only when this + * workspace can actually post it. An unknown or unavailable format is + * dropped rather than refused: the card then asks, which is exactly what + * it does when the user named no format at all. + * + * @param array $catalog the payload built by {@see PostGenerationCatalog::forWorkspace()} + */ + private function resolveFormat(Request $request, array $catalog): ?string + { + if (! $request->filled('format')) { + return null; + } + + $format = $request->string('format')->trim()->value(); + + $available = array_map( + fn (array $entry): string => data_get($entry, 'value'), + data_get($catalog, 'formats', []), + ); + + return in_array($format, $available, true) ? $format : null; + } +} diff --git a/app/Ai/Tools/Post/UpdatePostTool.php b/app/Ai/Tools/Post/UpdatePostTool.php new file mode 100644 index 000000000..17b826fd2 --- /dev/null +++ b/app/Ai/Tools/Post/UpdatePostTool.php @@ -0,0 +1,64 @@ + + */ + public function schema(JsonSchema $schema): array + { + return [ + 'post_id' => $schema->string()->required()->description('The id of the post to update.'), + 'content' => $schema->string()->description('The new text content for the post.'), + ]; + } + + protected function run(Request $request): string + { + $post = $this->resolvePost($request->string('post_id')->value()); + + if ($post === null) { + return $this->error(__('chat.tools.post_not_found')); + } + + $data = []; + + if ($request->filled('content')) { + $data['content'] = $request->string('content')->value(); + } + + $result = UpdatePost::execute($this->workspace, $post, $data); + + if (data_get($result, 'action') === PostAction::Finalized) { + return $this->error(PostStatusRules::editBlockedMessage()); + } + + return $this->json([ + 'data' => (new ChatPostResource($post->fresh()->load('postPlatforms.socialAccount')))->withFullContent()->resolve(), + ]); + } +} diff --git a/app/Ai/Tools/ToolReplayer.php b/app/Ai/Tools/ToolReplayer.php new file mode 100644 index 000000000..947923c9a --- /dev/null +++ b/app/Ai/Tools/ToolReplayer.php @@ -0,0 +1,312 @@ + + */ + private const REPLAYABLE = [ + 'list_posts' => ListPostsTool::class, + 'get_post' => GetPostTool::class, + 'start_post_generation' => StartPostGenerationTool::class, + ]; + + /** + * Matched by the early branch in {@see replay()}, which is what keeps this + * tool from being re-run. Never add it to REPLAYABLE either — see the + * class docblock for why the branch, not the map, is the guarantee. + */ + private const GENERATE_POST = 'generate_post'; + + private const START_POST_GENERATION = 'start_post_generation'; + + /** + * Longest a generation is given before it is treated as over. Mirrors the + * client's own bound (POST_CREATION_TIMEOUT_MS in + * resources/js/composables/echo/usePostCreation.ts, itself carried over + * from the loading screen's GENERATION_TIMEOUT_MS), so the two agree on + * when a generation stopped being in flight. Keep them in step. + */ + private const GENERATION_WINDOW_MINUTES = 16; + + /** + * @return array tool call id => JSON payload + */ + public function replay(WorkspaceConversation $conversation): array + { + $payloads = []; + $position = 0; + $lastGeneratePosition = $this->lastGeneratePostPosition($conversation); + + foreach ($conversation->messages as $message) { + $storedResults = collect($message->tool_results ?? [])->keyBy('id'); + + foreach ($message->tool_calls ?? [] as $call) { + $id = data_get($call, 'id'); + $stored = (string) data_get($storedResults->get($id), 'result', ''); + $name = data_get($call, 'name'); + $callPosition = $position++; + + if ($name === self::GENERATE_POST) { + $payloads[$id] = $this->withGeneratedPost($conversation, $message, $stored); + + continue; + } + + $class = self::REPLAYABLE[$name] ?? null; + + if ($class === null) { + $payloads[$id] = $stored; + + continue; + } + + try { + $tool = new $class($conversation->workspace, $conversation->user); + $fresh = $tool->handle(new Request((array) data_get($call, 'arguments', []))); + $payload = $this->isErrorPayload($fresh) ? $stored : $fresh; + + $payloads[$id] = $name === self::START_POST_GENERATION + ? $this->markSpent($payload, $callPosition < $lastGeneratePosition) + : $payload; + } catch (Throwable) { + // Belt-and-braces, not the primary guard: WorkspaceTool::handle() + // never lets run() throw, so this only catches a failure to even + // construct or dispatch the tool (e.g. the conversation's + // workspace or user relation no longer resolves). + $payloads[$id] = $stored; + } + } + } + + return $payloads; + } + + /** + * Position of the last generate_post call that actually STARTED a + * generation, counted over the same flattened call sequence {@see replay()} + * walks, or -1 when the conversation has none. + * + * A refused call does not count. generate_post answers with an `{"error": + * "..."}` payload whenever it declines — no AI access, exhausted credits, + * an invalid format or account — and in every one of those cases nothing + * was generated and nothing was billed. Treating an attempt as a + * generation would settle the card that collected the choices, so a user + * who topped up their credits and reopened the conversation would find + * their own choices frozen behind a disabled form, with no way forward but + * to ask for a fresh card. + * + * A call with no stored result at all is counted as a generation. That is + * the safe direction: the failure mode of over-counting is a card the user + * must ask to have re-offered, while under-counting bills them for a + * duplicate generation. + */ + private function lastGeneratePostPosition(WorkspaceConversation $conversation): int + { + $position = 0; + $last = -1; + + foreach ($conversation->messages as $message) { + $storedResults = collect($message->tool_results ?? [])->keyBy('id'); + + foreach ($message->tool_calls ?? [] as $call) { + $stored = (string) data_get($storedResults->get(data_get($call, 'id')), 'result', ''); + + if (data_get($call, 'name') === self::GENERATE_POST && ! $this->isErrorPayload($stored)) { + $last = $position; + } + + $position++; + } + } + + return $last; + } + + /** + * Mark a start_post_generation payload as already acted on. + * + * The card that renders this payload is a form: it collects the user's + * choices and submits them as a message, which is what makes the model + * call generate_post. That form is single-use, but `submitted` lives in + * the component, so a reopened conversation would hand the user a blank, + * fully interactive card sitting above the post it already produced — and + * a second submit would arrive with a fresh tool call id, so nothing + * downstream would deduplicate it and the account would be billed for a + * duplicate generation. + * + * The conversation itself is the record of what happened, and this class + * is where the whole history is already in hand: a generation card whose + * conversation went on to actually START a generation has been spent, and + * the card renders settled rather than interactive. + */ + private function markSpent(string $payload, bool $spent): string + { + if (! $spent) { + return $payload; + } + + $decoded = json_decode($payload, true); + + if (! is_array($decoded)) { + return $payload; + } + + data_set($decoded, 'data.spent', true); + + return $this->encode($decoded); + } + + /** + * Resolve a finished generation back into its post. + * + * generate_post dispatches StreamPostCreation and answers immediately with + * a creation id and the private channel PostCreationReady will announce + * the post on, so the stored result never contains the post itself. The + * card can subscribe to that channel while the conversation is open, but a + * conversation reopened later missed the broadcast for good — and it will + * never fire again. That is what `posts.creation_id` exists for: the post + * is found by lookup instead, and the card renders it straight away + * without subscribing to anything. + * + * Scoped to the conversation's own workspace, so a creation id that + * somehow named another workspace's post resolves to nothing rather than + * leaking it. + * + * Resolving nothing means one of two different things, and the payload + * says which. A generation started minutes ago may still be running, so + * that payload passes through untouched and the card subscribes and waits. + * A generation whose turn happened longer ago than the whole generation + * window with no post to show for it is over: the broadcast fired and was + * missed, or the job failed. Nothing is coming, and the card would + * otherwise sit spinning for the length of its own timeout implying work + * is in progress. That payload is marked `settled` so the card can say so + * on first paint. + */ + private function withGeneratedPost(WorkspaceConversation $conversation, WorkspaceConversationMessage $message, string $stored): string + { + $payload = json_decode($stored, true); + + if (! is_array($payload)) { + return $stored; + } + + $creationId = data_get($payload, 'data.creation_id'); + + if (! is_string($creationId) || $creationId === '') { + return $stored; + } + + $post = $conversation->workspace->posts() + ->with(['postPlatforms.socialAccount']) + ->where('creation_id', $creationId) + ->first(); + + if ($post === null) { + if (! $this->hasOutlivedGenerationWindow($message)) { + return $stored; + } + + data_set($payload, 'data.settled', true); + + return $this->encode($payload); + } + + data_set($payload, 'data.post', (new ChatPostResource($post))->withFullContent()->resolve()); + + return $this->encode($payload); + } + + /** + * Whether the turn that started this generation is older than the window a + * generation is given to finish, measured from the message's own + * `created_at` rather than from anything the client reports. + */ + private function hasOutlivedGenerationWindow(WorkspaceConversationMessage $message): bool + { + $createdAt = $message->created_at; + + return $createdAt !== null && $createdAt->lt(now()->subMinutes(self::GENERATION_WINDOW_MINUTES)); + } + + /** + * @param array $payload + */ + private function encode(array $payload): string + { + return json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } + + private function isErrorPayload(string $payload): bool + { + return data_get(json_decode($payload, true), 'error') !== null; + } +} diff --git a/app/Ai/Tools/WorkspaceTool.php b/app/Ai/Tools/WorkspaceTool.php new file mode 100644 index 000000000..71236a2f2 --- /dev/null +++ b/app/Ai/Tools/WorkspaceTool.php @@ -0,0 +1,123 @@ +workspace, so a prompt injection has nowhere to write + * one. + * 2. Role. A tool that mutates workspace data extends + * {@see WorkspaceWriteTool} and is refused for a member the workspace + * policy would refuse anywhere else. Reads stay open to every member, + * Viewers included. + * 3. Containment. A thrown exception becomes an error string the model can + * recover from, rather than a 500 that kills the stream. The real + * exception message is only ever logged — a caught Throwable can carry + * database internals (table/column names, host, the substituted SQL, in + * the case of a QueryException), so the model only ever sees a generic, + * translated message for that path. Errors raised deliberately inside + * run() (e.g. "post not found") are untouched and still reach the model. + */ +abstract class WorkspaceTool implements Tool +{ + public function __construct( + protected Workspace $workspace, + protected User $user, + ) {} + + /** + * The tool's snake_case name, used as the cross-boundary contract key by + * the SDK, the agent, and the frontend component registry. Never rely on + * ToolNameResolver's class_basename() fallback. + */ + abstract public function name(): string; + + public function handle(Request $request): string + { + if ($this->writeDenied()) { + return $this->error(__('chat.tools.forbidden')); + } + + try { + return $this->run($request); + } catch (Throwable $e) { + Log::warning('Chat tool failed', [ + 'tool' => static::class, + 'arguments' => $request->toArray(), + 'error' => $e->getMessage(), + ]); + + return $this->error(__('chat.tools.error')); + } + } + + abstract protected function run(Request $request): string; + + /** + * Whether this tool mutates workspace data. Only {@see WorkspaceWriteTool} + * answers true, so the role gate is declared once by inheritance instead + * of being re-implemented — and forgotten — per tool. + */ + protected function authorizesWrites(): bool + { + return false; + } + + /** + * The single authorization predicate for every write tool. + * + * `createPost` is the same workspace ability the web controller + * (App\Http\Controllers\App\PostController) and the MCP post tools + * enforce: owner, Admin and Member may write, Viewer may not. The chat + * path itself only checks `view` on the workspace and `useAi` on the + * account, neither of which is role-aware, so without this a Viewer + * could create, edit, schedule, publish and delete posts by asking. + * + * A denial must never throw: an AuthorizationException escaping a tool + * would kill the HTTP stream mid-turn. It resolves to an ordinary + * {@see error()} string so the model can tell the user it lacks + * permission. + */ + protected function writeDenied(): bool + { + return $this->authorizesWrites() && $this->user->cannot('createPost', $this->workspace); + } + + protected function json(mixed $data): string + { + return json_encode($data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); + } + + protected function error(string $message): string + { + return $this->json(['error' => $message]); + } + + /** + * Resolve a post inside this tool's workspace. Returns null for a missing + * id (including an empty or whitespace-only string, which is what + * `$request->string('post_id')->value()` yields when the argument is + * absent), a malformed id, or a post belonging to another workspace — the + * four are indistinguishable to the model on purpose. + */ + protected function resolvePost(?string $postId): ?Post + { + if (blank($postId)) { + return null; + } + + return $this->workspace->posts()->with(['postPlatforms.socialAccount'])->find($postId); + } +} diff --git a/app/Ai/Tools/WorkspaceWriteTool.php b/app/Ai/Tools/WorkspaceWriteTool.php new file mode 100644 index 000000000..dba896da0 --- /dev/null +++ b/app/Ai/Tools/WorkspaceWriteTool.php @@ -0,0 +1,23 @@ +where('status', Status::InProgress) + ->where('updated_at', '<', now()->subMinutes(10)) + ->toBase() + ->update(['status' => Status::Idle]); + + $this->info("Released {$released} stalled conversation(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/Post/CreatedVia.php b/app/Enums/Post/CreatedVia.php index bd7143328..9e7c806b2 100644 --- a/app/Enums/Post/CreatedVia.php +++ b/app/Enums/Post/CreatedVia.php @@ -10,4 +10,5 @@ enum CreatedVia: string case Mcp = 'mcp'; case Api = 'api'; case Automation = 'automation'; + case Chat = 'chat'; } diff --git a/app/Enums/PostHog/OnboardingEvent.php b/app/Enums/PostHog/OnboardingEvent.php index d09f96910..1bacbc548 100644 --- a/app/Enums/PostHog/OnboardingEvent.php +++ b/app/Enums/PostHog/OnboardingEvent.php @@ -10,4 +10,26 @@ enum OnboardingEvent: string case StepCompleted = 'onboarding.step_completed'; case StepSkipped = 'onboarding.step_skipped'; case Completed = 'onboarding.completed'; + case Persona = 'welcome.persona'; + case Goals = 'welcome.goals'; + case Referral = 'welcome.referral'; + case PublishMethod = 'welcome.publish_method'; + case Connect = 'welcome.connect'; + + /** + * Onboarding capture order through Stripe Checkout. + * + * @return list + */ + public static function funnel(): array + { + return [ + self::Persona->value, + self::Goals->value, + self::Referral->value, + self::PublishMethod->value, + self::Connect->value, + CheckoutEvent::Started->value, + ]; + } } diff --git a/app/Enums/PostHog/WelcomeEvent.php b/app/Enums/PostHog/WelcomeEvent.php deleted file mode 100644 index bab4a07ca..000000000 --- a/app/Enums/PostHog/WelcomeEvent.php +++ /dev/null @@ -1,29 +0,0 @@ - - */ - public static function funnel(): array - { - return [ - self::Persona->value, - self::Goals->value, - self::Referral->value, - self::Connect->value, - CheckoutEvent::Started->value, - ]; - } -} diff --git a/app/Enums/PostPlatform/ContentType.php b/app/Enums/PostPlatform/ContentType.php index 0d56e74cd..f4c47432e 100644 --- a/app/Enums/PostPlatform/ContentType.php +++ b/app/Enums/PostPlatform/ContentType.php @@ -534,6 +534,13 @@ public function requiresMedia(): bool * Content types that the AI generator currently supports. Reels/stories/ * videos are excluded because the AI flow only produces text + images. * + * PinterestCarousel is excluded for a different reason: the generation + * pipeline recognises exactly one carousel, {@see self::CAROUSEL_FORMAT}. + * Any other multi-image type runs as a single-image generation and comes + * back with one image, below its own minMediaCount(), so the post can + * never be published. Do not add a type here without teaching + * App\Jobs\Ai\StreamPostCreation how to generate it. + * * @return array */ public static function aiSupported(): array @@ -549,7 +556,6 @@ public static function aiSupported(): array self::MastodonPost, self::FacebookPost, self::PinterestPin, - self::PinterestCarousel, ]; } diff --git a/app/Enums/SocialAccount/Platform.php b/app/Enums/SocialAccount/Platform.php index e665ed453..db6ba843d 100644 --- a/app/Enums/SocialAccount/Platform.php +++ b/app/Enums/SocialAccount/Platform.php @@ -72,6 +72,14 @@ public function label(): string }; } + public function onboardingLabel(): string + { + return match ($this) { + self::X => 'X (Twitter)', + default => $this->label(), + }; + } + public function color(): string { return match ($this) { @@ -268,6 +276,54 @@ public function requiredPublishScopes(): array }; } + /** + * Whether the network exposes post-level impressions, reach, or views. + * Onboarding uses this to decide whether to fetch and show the latest post. + * Telegram has subscriber analytics only; LinkedIn personal, Bluesky, + * Mastodon, and Discord do not expose impression metrics. + */ + public function supportsImpressionAnalytics(): bool + { + return match ($this) { + self::Instagram, self::InstagramFacebook, self::Facebook, + self::X, self::Threads, self::TikTok, self::YouTube, + self::Pinterest, self::LinkedInPage => true, + self::LinkedIn, self::Bluesky, self::Mastodon, + self::Telegram, self::Discord => false, + }; + } + + /** + * Onboarding reach pitch: compare video/visual networks with other visual + * networks, and text/feed networks with other text networks. + * + * @return list + */ + public function onboardingReachComparisons(): array + { + $video = [ + self::TikTok, + self::YouTube, + self::Instagram, + self::Pinterest, + self::Facebook, + ]; + + $text = [ + self::Threads, + self::LinkedIn, + self::Bluesky, + self::Mastodon, + self::X, + ]; + + return match ($this) { + self::Instagram, self::InstagramFacebook, self::TikTok, + self::YouTube, self::Pinterest, self::Facebook => $video, + default => $text, + }; + } + public function supportsTextOnly(): bool { return match ($this) { diff --git a/app/Enums/User/PublishMethod.php b/app/Enums/User/PublishMethod.php new file mode 100644 index 000000000..25e95f710 --- /dev/null +++ b/app/Enums/User/PublishMethod.php @@ -0,0 +1,11 @@ +route('app.welcome.persona'); + return redirect()->route('app.onboarding'); } public function processing(Request $request): Response|RedirectResponse @@ -25,16 +25,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), ]); } diff --git a/app/Http/Controllers/App/ChatController.php b/app/Http/Controllers/App/ChatController.php new file mode 100644 index 000000000..d18835ce2 --- /dev/null +++ b/app/Http/Controllers/App/ChatController.php @@ -0,0 +1,111 @@ +resolveWorkspaceAndUser($request); + + return Inertia::render('chat/Index', [ + 'conversations' => ConversationResource::collection($this->listableQuery($workspace, $user)->get()), + ]); + } + + public function show(Request $request, string $conversation): Response + { + [$workspace, $user] = $this->resolveWorkspaceAndUser($request); + + $model = $this->findConversation($workspace, $user, $conversation)->load(['messages', 'workspace', 'user']); + + $payloads = app(ToolReplayer::class)->replay($model); + + return Inertia::render('chat/Index', [ + 'conversations' => ConversationResource::collection($this->listableQuery($workspace, $user)->get()), + 'conversation' => new ConversationResource($model), + 'messages' => $model->messages + ->map(fn (WorkspaceConversationMessage $message): array => (new ConversationMessageResource($message, $payloads))->resolve()) + ->all(), + ]); + } + + public function update(UpdateChatConversationRequest $request, string $conversation): RedirectResponse + { + [$workspace, $user] = $this->resolveWorkspaceAndUser($request); + + $model = $this->findConversation($workspace, $user, $conversation); + + $model->update(['title' => $request->validated('title')]); + + return back(); + } + + public function destroy(Request $request, string $conversation): RedirectResponse + { + [$workspace, $user] = $this->resolveWorkspaceAndUser($request); + + $model = $this->findConversation($workspace, $user, $conversation); + + $model->delete(); + + return redirect()->route('app.chat'); + } + + /** + * Resolve the current user's workspace, authorised for chat access. + * + * @return array{0: Workspace, 1: User} + */ + private function resolveWorkspaceAndUser(Request $request): array + { + /** @var User $user */ + $user = $request->user(); + + /** @var Workspace $workspace */ + $workspace = $user->currentWorkspace; + + $this->authorize('view', $workspace); + + return [$workspace, $user]; + } + + /** + * Resolve a single conversation the user owns, so another user's + * conversation 404s rather than 403s — matching how the rest of the + * app hides records it does not want to acknowledge. + * + * Deliberately scopeOwnedBy(), not scopeListable(): the title filter on + * scopeListable() exists only to keep the sidebar tidy, and titling + * runs in the background (GenerateConversationTitle) after the + * conversation already has messages. Resolving by the same predicate + * used for that presentation-only list would let a slow or permanently + * failed title job make a conversation the user is actively in — or + * already wrote messages to — unreachable, possibly forever. + */ + private function findConversation(Workspace $workspace, User $user, string $id): WorkspaceConversation + { + return WorkspaceConversation::query()->ownedBy($workspace->id, $user->id)->findOrFail($id); + } + + private function listableQuery(Workspace $workspace, User $user): Builder + { + return WorkspaceConversation::query()->listable($workspace->id, $user->id); + } +} diff --git a/app/Http/Controllers/App/ChatMessageController.php b/app/Http/Controllers/App/ChatMessageController.php new file mode 100644 index 000000000..145208679 --- /dev/null +++ b/app/Http/Controllers/App/ChatMessageController.php @@ -0,0 +1,210 @@ +user(); + + /** @var Workspace $workspace */ + $workspace = $user->currentWorkspace; + + $this->authorize('view', $workspace); + + $gate = Gate::inspect('useAi', $workspace->account); + if ($gate->denied()) { + return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED); + } + + $prompt = $this->prompt($request); + + $model = $this->claim($conversation, $workspace, $user, $prompt); + + return (new WorkspaceConversationAgent($workspace, $user)) + ->continue($model->id, as: $user) + ->stream($prompt) + ->usingVercelDataProtocol() + ->then(function (StreamedAgentResponse $response) use ($model, $workspace, $user): void { + RecordAiUsage::recordText( + workspace: $workspace, + promptTokens: $response->usage->promptTokens, + completionTokens: $response->usage->completionTokens, + provider: (string) $response->meta->provider, + model: (string) $response->meta->model, + userId: $user->id, + metadata: ['agent' => 'workspace_conversation'], + ); + + $model->update(['status' => Status::Idle]); + + if ($model->title === null) { + GenerateConversationTitle::dispatch($model->id); + } + }); + } + + /** + * Take exclusive ownership of the conversation for this turn. + * + * The row lock, the idle check, the user-message write and the flip to + * in-progress all live in one transaction on purpose. Locking only long + * enough to read the status would let two simultaneous requests both see + * Idle and both start streaming: the lock is released at commit, so the + * check has to happen while it is still held, and the status has to be + * written before it is released. Holding the lock across all four means the + * loser blocks on the SELECT ... FOR UPDATE, wakes up after the winner has + * committed InProgress, and gets a 409. + * + * The conversation is looked up with its client-supplied id rather than + * route-model binding: the very first message of a conversation targets a + * row that does not exist yet. + * + * The retries matter only off Postgres: when the row does not exist yet, MySQL + * and MariaDB take gap locks and deadlock the loser (1213) instead of blocking + * it. Retrying sends that request down the row-exists path, where it gets its + * 409 instead of a 500. + */ + private function claim( + string $conversation, + Workspace $workspace, + User $user, + Decisions|string $prompt, + ): WorkspaceConversation { + return DB::transaction(function () use ($conversation, $workspace, $user, $prompt): WorkspaceConversation { + $model = WorkspaceConversation::withTrashed() + ->lockForUpdate() + ->firstOrCreate( + ['id' => $conversation], + [ + 'workspace_id' => $workspace->id, + 'user_id' => $user->id, + 'status' => Status::Idle, + ], + ); + + abort_if( + $model->user_id !== $user->id || $model->workspace_id !== $workspace->id, + Response::HTTP_FORBIDDEN, + ); + + abort_if($model->trashed(), Response::HTTP_NOT_FOUND); + + abort_if( + ! $model->isIdle() && ! $this->hasStalledTurn($model), + Response::HTTP_CONFLICT, + __('chat.errors.turn_in_progress'), + ); + + // The user's message is persisted before the stream opens so a dropped + // connection never loses what they typed. The exact same string is handed + // to the agent below: WorkspaceConversationStore::storeUserMessage() is + // idempotent only against a trailing user row whose content matches the + // prompt byte for byte, so any trimming or decorating here would make the + // SDK's end-of-turn write a duplicate instead of a no-op... + if (is_string($prompt)) { + WorkspaceConversationMessage::create([ + 'workspace_conversation_id' => $model->id, + 'role' => Role::User, + 'content' => $prompt, + ]); + } + + // forceFill + save(), not update(): reclaiming a stalled turn leaves the + // status attribute unchanged, and Eloquent skips the query — and the + // timestamp — when nothing is dirty. The next request would then see the + // same stale updated_at and reclaim the conversation all over again... + $model->forceFill([ + 'status' => Status::InProgress, + 'updated_at' => now(), + ])->save(); + + return $model; + }, attempts: 3); + } + + /** + * Determine whether an in-progress turn has been abandoned and may be taken over. + * + * updated_at is restamped every time a turn is claimed, so it doubles as the + * turn's start time. + */ + private function hasStalledTurn(WorkspaceConversation $model): bool + { + return $model->updated_at !== null + && $model->updated_at->lt(now()->subMinutes(self::STALE_TURN_MINUTES)); + } + + /** + * Resolve the turn's prompt: a new user message, or the approval decisions + * that resume a run paused on a tool call. + */ + private function prompt(StoreChatMessageRequest $request): Decisions|string + { + $validated = $request->validated(); + $decisions = data_get($validated, 'decisions'); + + if (blank($decisions)) { + return (string) data_get($validated, 'message'); + } + + return Decisions::from(collect($decisions)->map( + fn (array $decision): Decision => data_get($decision, 'action') === 'approve' + ? Decision::approve() + : Decision::reject(data_get($decision, 'result')), + )->all()); + } +} diff --git a/app/Http/Controllers/App/OnboardingController.php b/app/Http/Controllers/App/OnboardingController.php index de66af804..11fc0d7ed 100644 --- a/app/Http/Controllers/App/OnboardingController.php +++ b/app/Http/Controllers/App/OnboardingController.php @@ -4,92 +4,407 @@ namespace App\Http\Controllers\App; -use App\Actions\Onboarding\ResolveOnboardingStatus; +use App\Actions\AccessToken\ListConnectedMcpClients; +use App\Actions\Billing\StartSubscriptionCheckout; +use App\Actions\Onboarding\FetchLatestSocialPost; +use App\Enums\Plan\Slug; +use App\Enums\PostHog\CheckoutEvent; use App\Enums\PostHog\OnboardingEvent; 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\PublishMethod; +use App\Enums\User\ReferralSource; +use App\Http\Requests\App\Onboarding\StoreOnboardingConnectRequest; +use App\Http\Requests\App\Onboarding\StoreOnboardingGoalsRequest; +use App\Http\Requests\App\Onboarding\StoreOnboardingPersonaRequest; +use App\Http\Requests\App\Onboarding\StoreOnboardingPublishMethodRequest; +use App\Http\Requests\App\Onboarding\StoreOnboardingReferralSourceRequest; use App\Http\Resources\App\SocialAccountResource; +use App\Models\Plan; +use App\Models\SocialAccount; +use App\Models\User; use App\Services\PostHogService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response as InertiaResponse; use Symfony\Component\HttpFoundation\Response; +use Throwable; class OnboardingController extends Controller { - public function __construct( - private readonly ResolveOnboardingStatus $resolveOnboardingStatus, - private readonly PostHogService $postHog, - ) {} + public function show(Request $request, FetchLatestSocialPost $fetchLatest): InertiaResponse|RedirectResponse + { + if ($redirect = $this->redirectIfUnavailable($request)) { + return $redirect; + } + + $user = $request->user(); + $step = $this->currentStep($user); + + $props = $this->chatState($user); + + if ($step === 'connect') { + $props = [...$props, ...$this->connectState($user, $fetchLatest, deferLatestPost: true)]; + } + + return Inertia::render('onboarding/Chat', $props); + } - public function index(Request $request): InertiaResponse|RedirectResponse + public function storePersona(StoreOnboardingPersonaRequest $request, PostHogService $postHog): RedirectResponse|JsonResponse { + if ($redirect = $this->redirectIfUnavailable($request)) { + return $redirect; + } + $user = $request->user(); - $workspace = $user->currentWorkspace; - $account = $user->accountOrFail(); - // Pure read on GET — observers + POST complete/skip stamp progress. - $status = $this->resolveOnboardingStatus->handle($user); + $persona = (string) $request->validated('persona'); - if (data_get($status, 'dismissed_at') !== null) { - return redirect()->route('app.calendar'); + $user->update(['persona' => $persona]); + + $postHog->identify($user->id, [ + 'persona' => $persona, + ]); + $postHog->capture( + $user->id, + OnboardingEvent::Persona->value, + ['persona' => $persona], + $user->account, + ); + + return $this->advance($request, $user->fresh()); + } + + public function storeGoals(StoreOnboardingGoalsRequest $request, PostHogService $postHog): RedirectResponse|JsonResponse + { + if ($redirect = $this->redirectIfStepIncomplete($request)) { + return $redirect; } + $user = $request->user(); + $goals = array_values($request->validated('goals')); + + $user->update(['goals' => $goals]); + + $postHog->identify($user->id, [ + 'goals' => $goals, + ]); + $postHog->capture( + $user->id, + OnboardingEvent::Goals->value, + ['goals' => $goals], + $user->account, + ); + + return $this->advance($request, $user->fresh()); + } + + public function storeReferralSource( + StoreOnboardingReferralSourceRequest $request, + FetchLatestSocialPost $fetchLatest, + PostHogService $postHog, + ): RedirectResponse|JsonResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { + return $redirect; + } + + $user = $request->user(); + $referralSource = (string) $request->validated('referral_source'); + + $user->update(['referral_source' => $referralSource]); + + $postHog->identify($user->id, [ + 'referral_source' => $referralSource, + ]); + $postHog->capture( + $user->id, + OnboardingEvent::Referral->value, + ['referral_source' => $referralSource], + $user->account, + ); + + return $this->advance($request, $user->fresh(), $fetchLatest); + } + + public function storePublishMethod( + StoreOnboardingPublishMethodRequest $request, + FetchLatestSocialPost $fetchLatest, + PostHogService $postHog, + ): RedirectResponse|JsonResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } + + $user = $request->user(); + $workspace = $user->currentWorkspace; + if ( - $account->isOnboardingOpen() - && $user->isAccountOwner() - && ! data_get($status, 'all_complete') + $workspace === null + || $workspace->socialAccounts()->where('status', Status::Connected)->doesntExist() ) { - $this->postHog->capture( + return $this->advance($request, $user, $fetchLatest); + } + + $publishMethod = (string) $request->validated('publish_method'); + + $user->update(['publish_method' => $publishMethod]); + + $postHog->identify($user->id, [ + 'publish_method' => $publishMethod, + ]); + $postHog->capture( + $user->id, + OnboardingEvent::PublishMethod->value, + ['publish_method' => $publishMethod], + $user->account, + ); + + return $this->advance($request, $user->fresh(), $fetchLatest); + } + + public function storeConnect( + StoreOnboardingConnectRequest $request, + StartSubscriptionCheckout $checkout, + PostHogService $postHog, + ): Response|RedirectResponse { + if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { + return $redirect; + } + + 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.onboarding'), + ); + + try { + $postHog->capture( + $user->id, + OnboardingEvent::Connect->value, + ['platforms' => $platforms], + $user->account, + ); + $postHog->capture( $user->id, - OnboardingEvent::Viewed->value, - account: $account, + CheckoutEvent::Started->value, + ['plan_name' => $plan->name, 'interval' => 'monthly'], + $user->account, ); + } catch (Throwable $e) { + report($e); + } + + return $response; + } + + public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse + { + $user = $request->user(); + + if ($user->account?->hasAppAccess()) { + return redirect()->route('app.calendar'); + } + + if ($user->isAccountOwner()) { + return redirect()->route('app.onboarding'); } - 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(), + return Inertia::render('onboarding/SubscriptionRequired', [ + 'ownerName' => $user->account?->owner?->name, ]); } - public function skipMcp(Request $request): RedirectResponse + /** + * @return array + */ + private function chatState(User $user): array { - abort_unless($request->user()->isAccountOwner(), Response::HTTP_FORBIDDEN); + $step = $this->currentStep($user); - $this->resolveOnboardingStatus->skipMcp($request->user()); + return [ + 'step' => $step, + 'history' => $this->chatHistory($user, $step), + 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), + 'selectedPersona' => $user->persona?->value, + 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), + 'selectedGoals' => $user->goals ?? [], + 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), + 'selectedReferral' => $user->referral_source?->value, + 'publishMethods' => array_map(fn (PublishMethod $method): string => $method->value, PublishMethod::cases()), + 'selectedPublishMethod' => $user->publish_method?->value, + ]; + } + + /** + * @return array + */ + private function connectState(User $user, FetchLatestSocialPost $fetchLatest, bool $deferLatestPost = false): array + { + $workspace = $user->currentWorkspace; - return back(); + abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); + + $accounts = $workspace->socialAccounts()->orderBy('id')->get(); + $connected = $accounts->first( + fn (SocialAccount $account): bool => $account->status === Status::Connected + && $account->platform->supportsImpressionAnalytics(), + ); + + $latestPost = null; + + if ($connected !== null) { + $latestPost = $deferLatestPost + ? Inertia::defer(fn (): ?array => $fetchLatest->handle($connected)) + : $fetchLatest->handle($connected); + } + + return [ + 'platforms' => array_map( + function (array $option): array { + $platform = SocialPlatform::tryFrom((string) data_get($option, 'value')); + + if ($platform !== null) { + $option['label'] = $platform->onboardingLabel(); + } + + return $option; + }, + SocialPlatform::connectableOptions(), + ), + 'accounts' => SocialAccountResource::collection($accounts)->resolve(), + 'latestPostNetwork' => $connected?->platform->network(), + 'latestPost' => $latestPost, + 'mcpUrl' => route('mcp.trypost'), + 'connectedClients' => ListConnectedMcpClients::forUser($user, $workspace), + ]; } - public function complete(Request $request): RedirectResponse + private function advance(Request $request, User $user, ?FetchLatestSocialPost $fetchLatest = null): RedirectResponse|JsonResponse { - $user = $request->user(); + if (! $request->expectsJson()) { + return redirect()->route('app.onboarding'); + } - abort_unless($user->isAccountOwner(), Response::HTTP_FORBIDDEN); + $state = $this->chatState($user); - $account = $user->accountOrFail(); + if (data_get($state, 'step') === 'connect') { + abort_unless($fetchLatest instanceof FetchLatestSocialPost, Response::HTTP_INTERNAL_SERVER_ERROR); - if ($account->isOnboardingDismissed()) { - return redirect()->route('app.calendar'); + $state = [...$state, ...$this->connectState($user, $fetchLatest)]; } - if ($account->isOnboardingCompleted()) { + return response()->json($state); + } + + private function currentStep(User $user): string + { + if (! $user->persona) { + return 'persona'; + } + + if (! Goal::containsCurrent($user->goals)) { + return 'goals'; + } + + if (! $user->referral_source) { + return 'referral'; + } + + return 'connect'; + } + + private function redirectIfStepIncomplete( + Request $request, + bool $requireGoals = false, + bool $requireReferral = false, + ): ?RedirectResponse { + if ($redirect = $this->redirectIfUnavailable($request)) { + return $redirect; + } + + $user = $request->user(); + + if (! $user->persona) { return redirect()->route('app.onboarding'); } - if (! data_get($this->resolveOnboardingStatus->handle($user), 'all_complete')) { + if ($requireGoals && ! Goal::containsCurrent($user->goals)) { return redirect()->route('app.onboarding'); } - $this->resolveOnboardingStatus->markCompleted($user); + if ($requireReferral && ! $user->referral_source) { + return redirect()->route('app.onboarding'); + } + + return null; + } + + /** + * Answered onboarding turns before the current step, reconstructed from + * stored user fields so a reload still looks like a chat thread. + * + * @return list}> + */ + private function chatHistory(User $user, string $currentStep): array + { + $history = []; + + if ($currentStep !== 'persona' && $user->persona) { + $history[] = [ + 'step' => 'persona', + 'values' => [$user->persona->value], + ]; + } + + if (in_array($currentStep, ['referral', 'connect'], true) && Goal::containsCurrent($user->goals)) { + $allowed = array_map(fn (Goal $goal): string => $goal->value, Goal::cases()); + + $history[] = [ + 'step' => 'goals', + 'values' => array_values(array_intersect($user->goals ?? [], $allowed)), + ]; + } + + if ($currentStep === 'connect' && $user->referral_source) { + $history[] = [ + 'step' => 'referral', + 'values' => [$user->referral_source->value], + ]; + } + + return $history; + } + + private function redirectIfUnavailable(Request $request): ?RedirectResponse + { + $user = $request->user(); + + // Match EnsureAccountReady — generic-trial (no-card) users already have + // app access and must not be sent through Stripe checkout again. + // Self-hosted always has app access, so onboarding/checkout is skipped too. + if ($user->account?->hasAppAccess()) { + return redirect()->route('app.calendar'); + } + + // Members can't check out — hold them on a dedicated screen instead of + // walking an ICP flow they can never finish. + if (! $user->isAccountOwner()) { + return redirect()->route('app.onboarding.subscription-required'); + } - return redirect()->route('app.onboarding'); + return null; } } diff --git a/app/Http/Controllers/App/PostAiCreateController.php b/app/Http/Controllers/App/PostAiCreateController.php deleted file mode 100644 index 54d5917b1..000000000 --- a/app/Http/Controllers/App/PostAiCreateController.php +++ /dev/null @@ -1,77 +0,0 @@ -user()->currentWorkspace; - - $this->authorize('createPost', $workspace); - - $gate = Gate::inspect('useAi', $workspace->account); - if ($gate->denied()) { - return response()->json(['message' => $gate->message()], Response::HTTP_PAYMENT_REQUIRED); - } - - $socialAccountId = $request->input('social_account_id'); - - if ($socialAccountId) { - $owned = SocialAccount::where('id', $socialAccountId) - ->where('workspace_id', $workspace->id) - ->exists(); - - if (! $owned) { - abort(Response::HTTP_FORBIDDEN); - } - } - - $creationId = $request->string('creation_id')->toString(); - - StreamPostCreation::dispatch( - userId: $request->user()->id, - creationId: $creationId, - workspaceId: $workspace->id, - format: $request->string('format')->toString(), - socialAccountId: $socialAccountId, - imageCount: (int) $request->input('image_count', 0), - prompt: $request->string('prompt')->toString(), - date: $request->input('date'), - template: $request->input('template', 'image_card'), - applyBrandVisuals: $request->boolean('apply_brand_visuals', true), - ); - - return response()->json([ - 'creation_id' => $creationId, - 'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}", - ], Response::HTTP_ACCEPTED); - } - - public function loading(Request $request, string $creationId): InertiaResponse - { - return Inertia::render('posts/ai/Loading', [ - 'creationId' => $creationId, - 'channel' => "user.{$request->user()->id}.ai-creation.{$creationId}", - 'imageCount' => (int) $request->query('images', '0'), - 'format' => (string) $request->query('format', ''), - 'prompt' => (string) $request->query('prompt', ''), - 'socialAccountId' => $request->query('social_account_id') ?: null, - 'date' => $request->query('date') ?: null, - 'template' => (string) $request->query('template', 'image_card'), - 'applyBrandVisuals' => $request->boolean('apply_brand_visuals', true), - ]); - } -} diff --git a/app/Http/Controllers/App/PostController.php b/app/Http/Controllers/App/PostController.php index 61981c895..ea2b30dcf 100644 --- a/app/Http/Controllers/App/PostController.php +++ b/app/Http/Controllers/App/PostController.php @@ -10,8 +10,6 @@ use App\Actions\Post\SyncPostPlatforms; use App\Actions\Post\UpdatePost; use App\Actions\SocialAccount\ListPinterestBoards; -use App\Ai\Templates\AiContentTemplate; -use App\Ai\Templates\AiTemplateRegistry; use App\Enums\Post\Action as PostAction; use App\Enums\Post\CreatedVia; use App\Enums\Post\Status as PostStatus; @@ -20,7 +18,6 @@ use App\Http\Requests\App\Post\UpdatePostRequest; use App\Http\Resources\Api\PostResource; use App\Http\Resources\App\PlatformConfigResource; -use App\Http\Resources\App\SocialAccountResource; use App\Models\Post; use App\Models\PostPlatform; use App\Services\Post\PostMetricsFetcher; @@ -139,33 +136,6 @@ public function calendar(Request $request): Response|RedirectResponse ]); } - public function create(Request $request): Response - { - $workspace = $request->user()->currentWorkspace; - - $this->authorize('createPost', $workspace); - - $registry = app(AiTemplateRegistry::class); - - $templates = array_map(fn (AiContentTemplate $t) => [ - 'key' => $t->key(), - 'name' => trans($t->name()), - 'description' => trans($t->description()), - 'preview' => $t->previewAsset(), - 'needs_account' => $t->needsAccount(), - 'supported_formats' => $t->supportedFormats(), - 'applies_brand_visuals' => $t->appliesBrandVisuals(), - ], $registry->all()); - - return Inertia::render('posts/Create', [ - 'date' => $request->query('date'), - 'socialAccounts' => SocialAccountResource::collection( - $workspace->socialAccounts()->active()->get() - ), - 'templates' => $templates, - ]); - } - public function store(StorePostRequest $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response { $workspace = $request->user()->currentWorkspace; diff --git a/app/Http/Controllers/App/WelcomeController.php b/app/Http/Controllers/App/WelcomeController.php deleted file mode 100644 index d4267c7a8..000000000 --- a/app/Http/Controllers/App/WelcomeController.php +++ /dev/null @@ -1,270 +0,0 @@ -redirectIfUnavailable($request)) { - return $redirect; - } - - return Inertia::render('welcome/Persona', [ - 'personas' => array_map(fn (Persona $persona): string => $persona->value, Persona::cases()), - 'selected' => $request->user()->persona?->value, - ]); - } - - public function storePersona(StoreWelcomePersonaRequest $request, PostHogService $postHog): RedirectResponse - { - if ($redirect = $this->redirectIfUnavailable($request)) { - return $redirect; - } - - $user = $request->user(); - $persona = (string) $request->validated('persona'); - - $user->update(['persona' => $persona]); - - $postHog->identify($user->id, [ - 'persona' => $persona, - ]); - $postHog->capture( - $user->id, - WelcomeEvent::Persona->value, - ['persona' => $persona], - $user->account, - ); - - return redirect()->route('app.welcome.goals'); - } - - public function goals(Request $request): InertiaResponse|RedirectResponse - { - if ($redirect = $this->redirectIfStepIncomplete($request)) { - return $redirect; - } - - $user = $request->user(); - - return Inertia::render('welcome/Goals', [ - 'goals' => array_map(fn (Goal $goal): string => $goal->value, Goal::cases()), - 'selected' => $user->goals ?? [], - ]); - } - - public function storeGoals(StoreWelcomeGoalsRequest $request, PostHogService $postHog): RedirectResponse - { - if ($redirect = $this->redirectIfStepIncomplete($request)) { - return $redirect; - } - - $user = $request->user(); - $goals = array_values($request->validated('goals')); - - $user->update(['goals' => $goals]); - - $postHog->identify($user->id, [ - 'goals' => $goals, - ]); - $postHog->capture( - $user->id, - WelcomeEvent::Goals->value, - ['goals' => $goals], - $user->account, - ); - - return redirect()->route('app.welcome.referral-source'); - } - - public function referralSource(Request $request): InertiaResponse|RedirectResponse - { - if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { - return $redirect; - } - - $user = $request->user(); - - return Inertia::render('welcome/ReferralSource', [ - 'sources' => array_map(fn (ReferralSource $source): string => $source->value, ReferralSource::cases()), - 'selected' => $user->referral_source?->value, - ]); - } - - public function storeReferralSource( - StoreWelcomeReferralSourceRequest $request, - PostHogService $postHog, - ): RedirectResponse { - if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true)) { - return $redirect; - } - - $user = $request->user(); - $referralSource = (string) $request->validated('referral_source'); - - $user->update(['referral_source' => $referralSource]); - - $postHog->identify($user->id, [ - 'referral_source' => $referralSource, - ]); - $postHog->capture( - $user->id, - WelcomeEvent::Referral->value, - ['referral_source' => $referralSource], - $user->account, - ); - - return redirect()->route('app.welcome.connect'); - } - - public function connect(Request $request): InertiaResponse|RedirectResponse - { - if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { - return $redirect; - } - - $workspace = $request->user()->currentWorkspace; - - abort_unless($workspace !== null, Response::HTTP_NOT_FOUND); - - return Inertia::render('welcome/Connect', [ - 'platforms' => SocialPlatform::connectableOptions(), - 'accounts' => SocialAccountResource::collection( - $workspace->socialAccounts()->orderBy('id')->get(), - )->resolve(), - ]); - } - - public function storeConnect( - StoreWelcomeConnectRequest $request, - StartSubscriptionCheckout $checkout, - PostHogService $postHog, - ): Response|RedirectResponse { - if ($redirect = $this->redirectIfStepIncomplete($request, requireGoals: true, requireReferral: true)) { - return $redirect; - } - - 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], - $user->account, - ); - $postHog->capture( - $user->id, - CheckoutEvent::Started->value, - ['plan_name' => $plan->name, 'interval' => 'monthly'], - $user->account, - ); - } catch (Throwable $e) { - report($e); - } - - return $response; - } - - public function subscriptionRequired(Request $request): InertiaResponse|RedirectResponse - { - $user = $request->user(); - - if ($user->account?->hasAppAccess()) { - return redirect()->route('app.calendar'); - } - - if ($user->isAccountOwner()) { - return redirect()->route('app.welcome.persona'); - } - - return Inertia::render('welcome/SubscriptionRequired', [ - 'ownerName' => $user->account?->owner?->name, - ]); - } - - private function redirectIfStepIncomplete( - Request $request, - bool $requireGoals = false, - bool $requireReferral = false, - ): ?RedirectResponse { - if ($redirect = $this->redirectIfUnavailable($request)) { - return $redirect; - } - - $user = $request->user(); - - if (! $user->persona) { - return redirect()->route('app.welcome.persona'); - } - - if ($requireGoals && ! Goal::containsCurrent($user->goals)) { - return redirect()->route('app.welcome.goals'); - } - - if ($requireReferral && ! $user->referral_source) { - return redirect()->route('app.welcome.referral-source'); - } - - return null; - } - - private function redirectIfUnavailable(Request $request): ?RedirectResponse - { - $user = $request->user(); - - // Match EnsureAccountReady — generic-trial (no-card) users already have - // app access and must not be sent through Stripe checkout again. - // Self-hosted always has app access, so welcome/checkout is skipped too. - if ($user->account?->hasAppAccess()) { - return redirect()->route('app.calendar'); - } - - // Members can't check out — hold them on a dedicated screen instead of - // walking an ICP flow they can never finish. - if (! $user->isAccountOwner()) { - return redirect()->route('app.welcome.subscription-required'); - } - - return null; - } -} diff --git a/app/Http/Controllers/Auth/GitHubController.php b/app/Http/Controllers/Auth/GitHubController.php index 2a04ca5e9..f128e1f92 100644 --- a/app/Http/Controllers/Auth/GitHubController.php +++ b/app/Http/Controllers/Auth/GitHubController.php @@ -130,6 +130,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $githubUser): return redirect()->route('app.invites.show', $invite); } - return redirect()->route('app.welcome'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Controllers/Auth/GoogleController.php b/app/Http/Controllers/Auth/GoogleController.php index 8cc59c1d3..d41cbf7ea 100644 --- a/app/Http/Controllers/Auth/GoogleController.php +++ b/app/Http/Controllers/Auth/GoogleController.php @@ -122,6 +122,6 @@ private function registerNewUser(\Laravel\Socialite\Contracts\User $googleUser): return redirect()->route('app.invites.show', $invite); } - return redirect()->route('app.welcome'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 0a0a46b6a..ce6365ecb 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -52,6 +52,6 @@ public function store(RegisterRequest $request): RedirectResponse return redirect()->route('app.invites.show', $invite); } - return redirect()->route('app.welcome'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Controllers/Auth/SocialController.php b/app/Http/Controllers/Auth/SocialController.php index a2793ec98..d0c596eb3 100644 --- a/app/Http/Controllers/Auth/SocialController.php +++ b/app/Http/Controllers/Auth/SocialController.php @@ -168,10 +168,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 { @@ -181,7 +177,6 @@ protected function popupCallback(bool $success, string $message, ?string $platfo 'success' => $success, 'message' => $message, 'platform' => $platform, - 'onboardingProgress' => false, ]); } } diff --git a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php index 87e5ddb5f..138052443 100644 --- a/app/Http/Middleware/Api/LoadWorkspaceFromToken.php +++ b/app/Http/Middleware/Api/LoadWorkspaceFromToken.php @@ -13,6 +13,23 @@ class LoadWorkspaceFromToken { + /** + * MCP methods that only complete the client handshake. They must work + * during onboarding (no subscription yet) so Claude / ChatGPT / Cursor can + * connect. Any other method — especially tools/call — stays gated. + * + * @var list + */ + private const MCP_HANDSHAKE_METHODS = [ + 'initialize', + 'notifications/initialized', + 'tools/list', + 'resources/list', + 'resources/templates/list', + 'prompts/list', + 'ping', + ]; + public function handle(Request $request, Closure $next, ?string $context = null): Response { $user = $request->user(); @@ -63,8 +80,13 @@ public function handle(Request $request, Closure $next, ?string $context = null) } // Match web access (EnsureAccountReady): Stripe subscription OR generic - // no-card trial when REQUIRE_CARD_FOR_TRIAL is disabled. - if (! config('trypost.self_hosted') && ! $workspace->account?->hasAppAccess()) { + // no-card trial when REQUIRE_CARD_FOR_TRIAL is disabled. MCP handshake + // is the onboarding exception — connect the client, but do no work. + if ( + ! config('trypost.self_hosted') + && ! $workspace->account?->hasAppAccess() + && ! $this->isMcpHandshake($context, $request) + ) { return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED); } @@ -75,4 +97,25 @@ public function handle(Request $request, Closure $next, ?string $context = null) return $next($request); } + + /** + * Fail closed: batch JSON-RPC, missing method, or anything other than a + * known handshake method still requires a subscription. + */ + private function isMcpHandshake(?string $context, Request $request): bool + { + if ($context !== 'mcp') { + return false; + } + + $payload = $request->json()->all(); + + if ($payload === [] || array_is_list($payload)) { + return false; + } + + $method = data_get($payload, 'method'); + + return is_string($method) && in_array($method, self::MCP_HANDSHAKE_METHODS, true); + } } diff --git a/app/Http/Middleware/App/EnsureAccountReady.php b/app/Http/Middleware/App/EnsureAccountReady.php index 9e41604bd..b886e6b59 100644 --- a/app/Http/Middleware/App/EnsureAccountReady.php +++ b/app/Http/Middleware/App/EnsureAccountReady.php @@ -25,13 +25,13 @@ public function handle(Request $request, Closure $next): Response $account = $user->account; if (! $account?->hasAppAccess()) { - // Members can't finish Welcome checkout — send them straight to + // Members can't finish onboarding checkout — send them straight to // the hold screen instead of hopping through persona first. if (! $user->isAccountOwner()) { - return redirect()->route('app.welcome.subscription-required'); + return redirect()->route('app.onboarding.subscription-required'); } - return redirect()->route('app.welcome.persona'); + return redirect()->route('app.onboarding'); } } diff --git a/app/Http/Middleware/App/HandleInertiaRequests.php b/app/Http/Middleware/App/HandleInertiaRequests.php index 756ed7f83..74942e73f 100644 --- a/app/Http/Middleware/App/HandleInertiaRequests.php +++ b/app/Http/Middleware/App/HandleInertiaRequests.php @@ -4,17 +4,13 @@ namespace App\Http\Middleware\App; -use App\Actions\Onboarding\ResolveOnboardingStatus; use App\Enums\Auth\SocialAuthProvider; use App\Enums\PostPlatform\ContentType; use App\Http\Resources\App\HandleInertiaRequests\AuthAccountResource; 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 Illuminate\Http\Request; -use Inertia\DeferProp; -use Inertia\Inertia; use Inertia\Middleware; class HandleInertiaRequests extends Middleware @@ -53,7 +49,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'), @@ -80,41 +75,4 @@ public function shareOnce(Request $request): array '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); - - 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', - ); - } } diff --git a/app/Http/Requests/App/Ai/StartPostCreationRequest.php b/app/Http/Requests/App/Ai/StartPostCreationRequest.php deleted file mode 100644 index 10b33527c..000000000 --- a/app/Http/Requests/App/Ai/StartPostCreationRequest.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - public function rules(): array - { - $allowedFormats = array_map(fn (ContentType $t) => $t->value, ContentType::aiSupported()); - $allowedFormats[] = ContentType::CAROUSEL_FORMAT; - - return [ - 'creation_id' => ['required', 'string', 'uuid'], - 'format' => [ - 'required', - 'string', - Rule::in($allowedFormats), - ], - 'social_account_id' => ['nullable', 'uuid'], - 'image_count' => ['nullable', 'integer', 'min:0', 'max:10'], - 'prompt' => AiPromptRules::wizardPromptRule(), - 'date' => ['nullable', 'date_format:Y-m-d'], - 'template' => ['sometimes', 'string', Rule::enum(ContentStyle::class)], - 'apply_brand_visuals' => ['sometimes', 'boolean'], - ]; - } - - public function withValidator(Validator $validator): void - { - $validator->after(function (Validator $validator): void { - $style = ContentStyle::tryFrom((string) $this->input('template', ContentStyle::default()->value)) - ?? ContentStyle::default(); - - if ($style->needsAccount() && blank($this->input('social_account_id'))) { - $validator->errors()->add('social_account_id', trans('validation.required', ['attribute' => 'social account'])); - } - }); - } -} diff --git a/app/Http/Requests/App/Chat/StoreChatMessageRequest.php b/app/Http/Requests/App/Chat/StoreChatMessageRequest.php new file mode 100644 index 000000000..ade7de73e --- /dev/null +++ b/app/Http/Requests/App/Chat/StoreChatMessageRequest.php @@ -0,0 +1,35 @@ +> + */ + public function rules(): array + { + return [ + 'message' => ['nullable', 'string', 'max:10000', 'required_without:decisions', 'prohibits:decisions'], + 'decisions' => ['nullable', 'array', 'required_without:message'], + 'decisions.*' => ['array'], + 'decisions.*.action' => ['required', Rule::in(['approve', 'reject'])], + 'decisions.*.result' => ['nullable', 'string', 'max:1000'], + ]; + } +} diff --git a/app/Http/Requests/App/Chat/UpdateChatConversationRequest.php b/app/Http/Requests/App/Chat/UpdateChatConversationRequest.php new file mode 100644 index 000000000..b0ae47ca6 --- /dev/null +++ b/app/Http/Requests/App/Chat/UpdateChatConversationRequest.php @@ -0,0 +1,25 @@ +> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:250'], + ]; + } +} diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingConnectRequest.php similarity index 81% rename from app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php rename to app/Http/Requests/App/Onboarding/StoreOnboardingConnectRequest.php index 5cde55a62..f636bf0bb 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeConnectRequest.php +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingConnectRequest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Welcome; +namespace App\Http\Requests\App\Onboarding; use App\Enums\SocialAccount\Status; use App\Enums\User\Goal; @@ -10,7 +10,7 @@ use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; -class StoreWelcomeConnectRequest extends FormRequest +class StoreOnboardingConnectRequest extends FormRequest { /** * @var list|null @@ -62,8 +62,12 @@ public function withValidator(Validator $validator): void return; } + if ($user->publish_method === null) { + $validator->errors()->add('publish_method', __('onboarding.publish_method.required')); + } + if ($this->connectedPlatforms() === []) { - $validator->errors()->add('connect', __('welcome.connect.required')); + $validator->errors()->add('connect', __('onboarding.connect.required')); } }); } diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php similarity index 81% rename from app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php rename to app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php index 987fa2c4e..e127eab3c 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeGoalsRequest.php +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingGoalsRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Welcome; +namespace App\Http\Requests\App\Onboarding; use App\Enums\User\Goal; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreWelcomeGoalsRequest extends FormRequest +class StoreOnboardingGoalsRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingPersonaRequest.php similarity index 80% rename from app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php rename to app/Http/Requests/App/Onboarding/StoreOnboardingPersonaRequest.php index c22f29fc7..5f07f055b 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomePersonaRequest.php +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingPersonaRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Welcome; +namespace App\Http\Requests\App\Onboarding; use App\Enums\User\Persona; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreWelcomePersonaRequest extends FormRequest +class StoreOnboardingPersonaRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/App/Onboarding/StoreOnboardingPublishMethodRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingPublishMethodRequest.php new file mode 100644 index 000000000..c14859f33 --- /dev/null +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingPublishMethodRequest.php @@ -0,0 +1,27 @@ + + */ + public function rules(): array + { + return [ + 'publish_method' => ['required', Rule::enum(PublishMethod::class)], + ]; + } +} diff --git a/app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php b/app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php similarity index 80% rename from app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php rename to app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php index 960089ec0..47c9a5ae4 100644 --- a/app/Http/Requests/App/Welcome/StoreWelcomeReferralSourceRequest.php +++ b/app/Http/Requests/App/Onboarding/StoreOnboardingReferralSourceRequest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Http\Requests\App\Welcome; +namespace App\Http\Requests\App\Onboarding; use App\Enums\User\ReferralSource; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; -class StoreWelcomeReferralSourceRequest extends FormRequest +class StoreOnboardingReferralSourceRequest extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Resources/Chat/ChatPostMetricsResource.php b/app/Http/Resources/Chat/ChatPostMetricsResource.php new file mode 100644 index 000000000..5cf78ef88 --- /dev/null +++ b/app/Http/Resources/Chat/ChatPostMetricsResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'post_id' => $this->id, + 'platforms' => app(PostMetricsFetcher::class)->forPost($this->resource)->all(), + ]; + } +} diff --git a/app/Http/Resources/Chat/ChatPostResource.php b/app/Http/Resources/Chat/ChatPostResource.php new file mode 100644 index 000000000..3d7d827e4 --- /dev/null +++ b/app/Http/Resources/Chat/ChatPostResource.php @@ -0,0 +1,68 @@ +fullContent = true; + + return $this; + } + + /** + * @return array + */ + public function toArray(Request $request): array + { + $content = (string) $this->content; + $preview = str($content)->limit(self::PREVIEW_LENGTH)->value(); + $truncated = ! $this->fullContent && $preview !== $content; + + return [ + 'id' => $this->id, + 'content' => $truncated ? $preview : $content, + 'content_truncated' => $truncated, + 'status' => $this->status?->value, + 'scheduled_at' => $this->scheduled_at?->toIso8601String(), + 'published_at' => $this->published_at?->toIso8601String(), + 'platforms' => $this->whenLoaded('postPlatforms', fn (): array => $this->postPlatforms + ->map(fn ($platform): array => [ + 'platform' => $platform->socialAccount?->platform?->value, + 'handle' => $platform->socialAccount?->username, + 'status' => $platform->status?->value, + ])->all()), + ]; + } +} diff --git a/app/Http/Resources/Chat/ConversationMessageResource.php b/app/Http/Resources/Chat/ConversationMessageResource.php new file mode 100644 index 000000000..027d28129 --- /dev/null +++ b/app/Http/Resources/Chat/ConversationMessageResource.php @@ -0,0 +1,54 @@ + $payloads tool call id => JSON payload, from ToolReplayer::replay() + */ + public function __construct(WorkspaceConversationMessage $message, private readonly array $payloads = []) + { + parent::__construct($message); + } + + /** + * @return array + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'role' => $this->role?->value, + 'content' => $this->content, + 'parts' => $this->parts, + 'tool_calls' => $this->tool_calls, + 'payloads' => collect($this->tool_calls ?? []) + ->mapWithKeys(function (array $call): array { + $id = (string) data_get($call, 'id'); + + return [$id => data_get($this->payloads, $id, '')]; + }) + ->all(), + ]; + } +} diff --git a/app/Http/Resources/Chat/ConversationResource.php b/app/Http/Resources/Chat/ConversationResource.php new file mode 100644 index 000000000..45288ad09 --- /dev/null +++ b/app/Http/Resources/Chat/ConversationResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'title' => $this->title, + 'status' => $this->status?->value, + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/Ai/GenerateConversationTitle.php b/app/Jobs/Ai/GenerateConversationTitle.php new file mode 100644 index 000000000..ffb0e6627 --- /dev/null +++ b/app/Jobs/Ai/GenerateConversationTitle.php @@ -0,0 +1,132 @@ +onQueue('ai'); + } + + public function handle(): void + { + $conversation = WorkspaceConversation::find($this->conversationId); + + if ($conversation === null || $conversation->title !== null) { + return; + } + + $firstUserMessage = $conversation->messages()->where('role', Role::User)->first(); + + if ($firstUserMessage === null) { + return; + } + + $response = (new ConversationTitleGenerator)->prompt(Str::limit($firstUserMessage->content, 500)); + + $title = $this->sanitizeTitle($response->text); + + if ($title === null) { + return; + } + + $conversation->update(['title' => $title]); + } + + /** + * Defend against a chatty model response landing verbatim in the sidebar. + * + * The reliable signal that a model wrapped its answer in a preamble is + * that it double-quoted the answer, so extraction only fires in two + * narrow, unambiguous shapes (see extractQuoted()): the entire response + * is one quoted segment, or the response ends with a colon followed by a + * quoted segment. Anything else is used as-is. An apostrophe or single + * quote is never treated as a quote delimiter — pairing arbitrary + * apostrophes (e.g. two contractions anywhere in the response) matches + * the wrong span and corrupts the title. A leading-word-list heuristic + * ("Sure!", "Here's...") was also tried and rejected: it damages + * legitimate titles that start with one of those words followed by a + * colon (e.g. "Okay Computer: A Retrospective"). An occasionally chatty + * title is a far better failure than a silently corrupted one. + * + * Wrapping quotes and trailing punctuation are trimmed with a + * Unicode-aware regex, never `trim()`'s byte-wise charlist — passing + * multi-byte characters to `trim()` corrupts adjacent multi-byte text + * (e.g. CJK) whose bytes happen to collide with the charlist bytes. + */ + private function sanitizeTitle(string $text): ?string + { + $title = Str::squish($text); + $title = $this->extractQuoted($title) ?? $title; + $title = $this->trimWrappingQuotesAndSpace($title); + $title = (string) preg_replace('/\p{P}+$/u', '', $title); + $title = trim($this->trimWrappingQuotesAndSpace($title)); + + if ($title === '') { + return null; + } + + return Str::limit($title, 250, ''); + } + + /** + * Extract a title the model wrapped in double quotes, in one of two + * unambiguous shapes only: + * + * 1. The entire trimmed response is one double-quoted segment + * (straight `"…"` or curly `"…"`) — unwrap it. + * 2. The response ends with a colon, optional whitespace, and a + * double-quoted segment — take that segment. + * + * Anything else returns null so the caller falls back to the response + * unchanged. Single/curly-apostrophe quotes never delimit a match: an + * apostrophe is not a quote, and pairing arbitrary apostrophes in the + * response (e.g. two contractions) would capture the wrong span. + */ + private function extractQuoted(string $text): ?string + { + if (preg_match('/^"(.+)"$/u', $text, $matches) === 1 + || preg_match('/^“(.+)”$/u', $text, $matches) === 1) { + return $matches[1]; + } + + if (preg_match('/:\s*"([^"]+)"$/u', $text, $matches) === 1 + || preg_match('/:\s*“([^”]+)”$/u', $text, $matches) === 1) { + return $matches[1]; + } + + return null; + } + + /** + * Trim leading/trailing whitespace and wrapping quote characters, + * Unicode-aware. + */ + private function trimWrappingQuotesAndSpace(string $text): string + { + return (string) preg_replace('/^[\s"\'“”‘’]+|[\s"\'“”‘’]+$/u', '', $text); + } +} diff --git a/app/Jobs/Ai/StreamPostCreation.php b/app/Jobs/Ai/StreamPostCreation.php index eeeba767b..9d22e684f 100644 --- a/app/Jobs/Ai/StreamPostCreation.php +++ b/app/Jobs/Ai/StreamPostCreation.php @@ -205,6 +205,7 @@ private function createPostFromGenerated(Workspace $workspace, GeneratedPost $ge 'media' => $generated->media, 'date' => $this->date, 'created_via' => CreatedVia::Web, + 'creation_id' => $this->creationId, ]); if ($generated->contentType && $socialAccount) { diff --git a/app/Models/Post.php b/app/Models/Post.php index 4c1d764b4..3ec892878 100644 --- a/app/Models/Post.php +++ b/app/Models/Post.php @@ -36,6 +36,7 @@ class Post extends Model 'media', 'status', 'created_via', + 'creation_id', 'scheduled_at', 'published_at', ]; diff --git a/app/Models/User.php b/app/Models/User.php index 7557cb44c..3c8b31ce9 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,7 @@ use App\Enums\Auth\SocialAuthProvider; use App\Enums\Notification\Type as NotificationType; use App\Enums\User\Persona; +use App\Enums\User\PublishMethod; use App\Enums\User\ReferralSource; use App\Models\Traits\HasAccount; use App\Models\Traits\HasMedia; @@ -55,6 +56,7 @@ class User extends Authenticatable implements MustVerifyEmail, OAuthenticatable 'persona', 'goals', 'referral_source', + 'publish_method', ]; /** @@ -99,6 +101,7 @@ protected function casts(): array 'persona' => Persona::class, 'goals' => 'array', 'referral_source' => ReferralSource::class, + 'publish_method' => PublishMethod::class, ]; } diff --git a/app/Models/WorkspaceConversation.php b/app/Models/WorkspaceConversation.php new file mode 100644 index 000000000..20d48b88c --- /dev/null +++ b/app/Models/WorkspaceConversation.php @@ -0,0 +1,88 @@ + */ + use HasFactory, HasUuids, SoftDeletes; + + protected $fillable = [ + 'id', + 'workspace_id', + 'user_id', + 'title', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => Status::class, + ]; + } + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(WorkspaceConversationMessage::class)->oldest(); + } + + /** + * This user's conversations in this workspace, regardless of title. + * + * The sole ownership predicate: everything that needs to resolve a + * specific conversation for its owner (show/update/destroy) uses this, + * not scopeListable(), so a best-effort background job (title + * generation) can never make a conversation with real messages + * permanently unreachable by the person who wrote them. + */ + public function scopeOwnedBy(Builder $query, string $workspaceId, string $userId): Builder + { + return $query->where('workspace_id', $workspaceId) + ->where('user_id', $userId); + } + + /** + * Conversations shown in the sidebar: this user's, in this workspace, titled. + * + * The title filter is a presentation concern for the sidebar list only — + * it must never be reused to resolve a single conversation. See + * scopeOwnedBy(). + */ + public function scopeListable(Builder $query, string $workspaceId, string $userId): Builder + { + return $query->ownedBy($workspaceId, $userId) + ->whereNotNull('title') + ->latest('updated_at'); + } + + public function isIdle(): bool + { + return $this->status === Status::Idle; + } +} diff --git a/app/Models/WorkspaceConversationMessage.php b/app/Models/WorkspaceConversationMessage.php new file mode 100644 index 000000000..79a43cc39 --- /dev/null +++ b/app/Models/WorkspaceConversationMessage.php @@ -0,0 +1,52 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'id', + 'workspace_conversation_id', + 'role', + 'content', + 'parts', + 'tool_calls', + 'tool_results', + 'usage', + 'meta', + 'approval_state', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'role' => Role::class, + 'parts' => 'array', + 'tool_calls' => 'array', + 'tool_results' => 'array', + 'usage' => 'array', + 'meta' => 'array', + 'approval_state' => 'array', + ]; + } + + public function conversation(): BelongsTo + { + return $this->belongsTo(WorkspaceConversation::class, 'workspace_conversation_id'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 127191488..eef52d936 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -26,9 +26,12 @@ use App\Models\SubscriptionItem; use App\Models\User; use App\Models\Workspace; +use App\Models\WorkspaceConversation; +use App\Models\WorkspaceConversationMessage; use App\Models\WorkspaceInvite; use App\Models\WorkspaceLabel; use App\Models\WorkspaceSignature; +use App\Services\Ai\Conversations\WorkspaceConversationStore; use App\Services\PostHogService; use App\Socialite\DiscordProvider; use App\Socialite\InstagramProvider; @@ -48,6 +51,7 @@ use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Ai\Contracts\ConversationStore; use Laravel\Cashier\Cashier; use Laravel\Cashier\Events\WebhookReceived; use Laravel\Nightwatch\Facades\Nightwatch; @@ -68,6 +72,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->singleton(ConversationStore::class, WorkspaceConversationStore::class); + if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) { $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); $this->app->register(TelescopeServiceProvider::class); @@ -116,6 +122,8 @@ protected function configureMorphMap(): void 'subscriptionItem' => SubscriptionItem::class, 'user' => User::class, 'workspace' => Workspace::class, + 'workspaceConversation' => WorkspaceConversation::class, + 'workspaceConversationMessage' => WorkspaceConversationMessage::class, 'workspaceInvite' => WorkspaceInvite::class, 'workspaceLabel' => WorkspaceLabel::class, 'workspaceSignature' => WorkspaceSignature::class, diff --git a/app/Services/Ai/Conversations/WorkspaceConversationStore.php b/app/Services/Ai/Conversations/WorkspaceConversationStore.php new file mode 100644 index 000000000..a5c9fa395 --- /dev/null +++ b/app/Services/Ai/Conversations/WorkspaceConversationStore.php @@ -0,0 +1,500 @@ +toolCalls`. + */ + private const STRUCTURED_OUTPUT_TOOL = 'output_structured_data'; + + public function latestConversationId(string $participantType, string|int $participantId): ?string + { + throw new RuntimeException( + 'The conversation store cannot resume a conversation from a participant alone; the contract carries no workspace id, so a user-only lookup can return a conversation from another of their workspaces. Query WorkspaceConversation::listable($workspaceId, $userId) instead.' + ); + } + + public function storeConversation(?string $participantType, string|int|null $participantId, string $title): string + { + throw new RuntimeException( + 'Conversations are created by ChatMessageController with a client-supplied id; the agent must always be given an existing conversation via continue().' + ); + } + + /** + * Idempotent by design. ChatMessageController writes the user's message + * before the stream opens, so a dropped connection never loses what was + * typed; the SDK's RememberConversation middleware then calls this again + * at the end of the same turn. Without the guard, every turn stores the + * user's message twice. + */ + public function storeUserMessage( + string $conversationId, + ?string $participantType, + string|int|null $participantId, + AgentPrompt $prompt, + ): string { + $existing = $this->messages($conversationId) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->first(); + + if ($existing?->role === Role::User && $existing->content === $prompt->prompt) { + return $existing->id; + } + + return $this->write($conversationId, Role::User, $prompt->prompt)->id; + } + + public function storeAssistantMessage( + string $conversationId, + ?string $participantType, + string|int|null $participantId, + AgentPrompt $prompt, + AgentResponse $response, + ): ?string { + $toolResults = $response->toolResults->values(); + + if ($prompt->hasApprovalDecisions()) { + $recorded = $this->recordedToolResultIds($conversationId); + + $toolResults = $toolResults + ->reject(fn (ToolResult $result): bool => in_array($result->id, $recorded, true)) + ->values(); + + if (blank($response->text) && $response->toolCalls->isEmpty() && $toolResults->isEmpty()) { + return null; + } + } + + return $this->write($conversationId, Role::Assistant, $response->text, [ + 'parts' => $this->orderedParts($response) ?: null, + 'tool_calls' => $response->toolCalls->values()->toArray(), + 'tool_results' => $toolResults->toArray(), + 'usage' => $response->usage->toArray(), + 'meta' => $this->messageMeta($response), + 'approval_state' => $this->approvalState($response), + ])->id; + } + + /** + * Build the turn's renderable parts in the order the model produced them. + * + * `content` flattens a whole turn into one string and `tool_calls` is a + * flat list beside it, so a sentence the model said *before* calling a + * tool cannot be told apart from one it said after. Both a streamed + * turn's events and a generated turn's steps still carry that order, so + * it is captured here rather than discarded. + * + * @return array + */ + private function orderedParts(AgentResponse $response): array + { + return $response instanceof StreamedAgentResponse + ? $this->partsFromEvents($response->events) + : $this->partsFromSteps($response->steps); + } + + /** + * Walk a streamed turn's events, which arrive in the order the provider emitted them. + * + * @param Collection $events + * @return array + */ + private function partsFromEvents(Collection $events): array + { + $parts = []; + $text = ''; + + foreach ($events as $event) { + if ($event instanceof TextDelta) { + $text .= $event->delta; + + continue; + } + + if (! $event instanceof ToolCallEvent || $event->toolCall->name === self::STRUCTURED_OUTPUT_TOOL) { + continue; + } + + $parts = $this->appendTextPart($parts, $text); + $text = ''; + + $parts[] = $this->toolPart($event->toolCall); + } + + return $this->appendTextPart($parts, $text); + } + + /** + * Walk a generated turn's steps; one step is one model generation, so its + * text always precedes the calls that same generation asked for. + * + * @param Collection $steps + * @return array + */ + private function partsFromSteps(Collection $steps): array + { + $parts = []; + + foreach ($steps as $step) { + $parts = $this->appendTextPart($parts, $step->text); + + foreach ($step->toolCalls as $toolCall) { + if ($toolCall->name === self::STRUCTURED_OUTPUT_TOOL) { + continue; + } + + $parts[] = $this->toolPart($toolCall); + } + } + + return $parts; + } + + /** + * @param array $parts + * @return array + */ + private function appendTextPart(array $parts, string $text): array + { + if (trim($text) === '') { + return $parts; + } + + $parts[] = ['type' => 'text', 'text' => $text]; + + return $parts; + } + + /** + * The payload a tool part renders is fetched by call id through + * ToolReplayer, so a part only has to name the call it points at. + * + * @return array{type: string, id: string, name: string} + */ + private function toolPart(ToolCall $toolCall): array + { + return ['type' => 'tool', 'id' => $toolCall->id, 'name' => $toolCall->name]; + } + + /** + * @return Collection + */ + public function getLatestConversationMessages(string $conversationId, int $limit): Collection + { + $records = $this->messages($conversationId) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit($limit) + ->get() + ->reverse() + ->values(); + + /** + * A call resolved after an approval pause lands on a later row than the + * call, so gather every result id across the window to keep those calls + * while dropping legacy dangling ones. + * + * @var array $resolvedCallIds + */ + $resolvedCallIds = $records + ->flatMap(fn (WorkspaceConversationMessage $record): array => collect($record->tool_results ?? [])->pluck('id')->all()) + ->filter() + ->all(); + + return $records + ->flatMap(function (WorkspaceConversationMessage $record) use ($resolvedCallIds): array { + if ($record->role === Role::User) { + return [new UserMessage($record->content)]; + } + + $toolCalls = collect($record->tool_calls ?? [])->values(); + $toolResults = collect($record->tool_results ?? [])->values(); + + if ($toolCalls->isNotEmpty()) { + return $this->reconstructToolTurn($record, $toolCalls, $toolResults, $resolvedCallIds); + } + + if ($toolResults->isNotEmpty()) { + $messages = [new ToolResultMessage($toolResults->map(ToolResult::fromArray(...)))]; + + if (filled($record->content)) { + $messages[] = new AssistantMessage($record->content); + } + + return $messages; + } + + return [new AssistantMessage($record->content)]; + }) + ->skipWhile(fn (Message $message): bool => $message instanceof ToolResultMessage) + ->values(); + } + + /** + * Durably record resolved approval results on the paused turn before the run continues. + * + * @param array $toolResults + * + * @throws ApprovalMismatchException when no paused row matches the resolved results + */ + public function storeApprovalResults( + string $conversationId, + ?string $participantType, + string|int|null $participantId, + array $toolResults, + ): void { + if ($toolResults === []) { + return; + } + + $resultIds = array_map(fn (ToolResult $result): string => $result->id, $toolResults); + + DB::transaction(function () use ($conversationId, $toolResults, $resultIds): void { + $row = $this->pausedRows($conversationId) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->lockForUpdate() + ->get() + ->first(fn (WorkspaceConversationMessage $record): bool => array_intersect($this->pausedCallIds($record), $resultIds) !== []); + + if ($row === null) { + throw new ApprovalMismatchException('The approval results do not match a paused conversation turn.', collect()); + } + + $existing = collect($row->tool_results ?? []); + + $merged = $existing->merge( + collect($toolResults) + ->reject(fn (ToolResult $result): bool => $existing->contains('id', $result->id)) + ->map(fn (ToolResult $result): array => $result->toArray()) + ); + + $pending = collect(data_get($row->approval_state, 'pending', []))->except($resultIds); + + // Keep the marker after resolution so the resume dedup scan stays bounded + // to ever-paused rows, while each call's outcome lives in the merged results... + $row->forceFill([ + 'tool_results' => $merged->values()->all(), + 'approval_state' => ['pending' => $pending->all()], + ])->save(); + }); + } + + /** + * Rebuild the messages for a stored assistant turn that made tool calls, keeping a pause distinct from a completed turn. + * + * @param Collection> $toolCalls + * @param Collection> $toolResults + * @param array $resolvedCallIds Ids of calls answered anywhere in the window. + * @return array + */ + private function reconstructToolTurn( + WorkspaceConversationMessage $record, + Collection $toolCalls, + Collection $toolResults, + array $resolvedCallIds = [], + ): array { + $callIds = $toolCalls->pluck('id')->all(); + + [$priorResults, $ownResults] = $toolResults->partition( + fn (array $toolResult): bool => ! in_array(data_get($toolResult, 'id'), $callIds, true) + ); + + $ownResultIds = $ownResults->pluck('id')->all(); + + [$resolvedCalls, $pendingCalls] = $toolCalls->partition( + fn (array $toolCall): bool => in_array(data_get($toolCall, 'id'), $ownResultIds, true) + ); + + $pausedCallIds = $this->pausedCallIds($record); + + $isPause = $pendingCalls->isNotEmpty() + && $pendingCalls->every(fn (array $toolCall): bool => in_array(data_get($toolCall, 'id'), $pausedCallIds, true)); + + $messages = []; + + if ($priorResults->isNotEmpty()) { + $messages[] = new ToolResultMessage($priorResults->map(ToolResult::fromArray(...))->values()); + } + + $providerContentBlocks = data_get($record->meta, 'provider_content_blocks', []); + + if ($isPause && filled($providerContentBlocks)) { + $messages[] = new AssistantMessage( + $record->content, + $toolCalls->map(ToolCall::fromArray(...))->values(), + $providerContentBlocks, + data_get($record->meta, 'provider'), + ); + + if ($ownResults->isNotEmpty()) { + $messages[] = new ToolResultMessage($ownResults->map(ToolResult::fromArray(...))->values()); + } + + return $messages; + } + + // Calls already answered this turn are replayed with their results... + if ($resolvedCalls->isNotEmpty()) { + $messages[] = new AssistantMessage('', $resolvedCalls->map(ToolCall::fromArray(...))->values()); + $messages[] = new ToolResultMessage($ownResults->map(ToolResult::fromArray(...))->values()); + } + + $keptCalls = $pendingCalls->filter( + fn (array $toolCall): bool => in_array(data_get($toolCall, 'id'), $pausedCallIds, true) + || in_array(data_get($toolCall, 'id'), $resolvedCallIds, true) + )->values(); + + if ($keptCalls->isNotEmpty()) { + $messages[] = new AssistantMessage($record->content, $keptCalls->map(ToolCall::fromArray(...))->values()); + } elseif (filled($record->content)) { + $messages[] = new AssistantMessage($record->content); + } + + return $messages; + } + + /** + * Get every tool-result id recorded on the conversation's approval-paused rows, the only rows a resume can duplicate. + * + * @return array + */ + private function recordedToolResultIds(string $conversationId): array + { + return $this->pausedRows($conversationId) + ->pluck('tool_results') + ->flatMap(fn (?array $results): array => collect($results ?? [])->pluck('id')->all()) + ->filter() + ->values() + ->all(); + } + + /** + * Mark a paused assistant row with the tool-call ids pending a decision, or null when the turn is not a pause. + * + * @return array{pending: array}|null + */ + private function approvalState(AgentResponse $response): ?array + { + if (! $response->hasPendingApprovals()) { + return null; + } + + return [ + 'pending' => $response->pendingApprovals + ->mapWithKeys(fn (PendingApproval $approval): array => [$approval->id => $approval->reason]) + ->all(), + ]; + } + + /** + * Get the tool-call ids a stored row recorded as pending a decision. + * + * @return array + */ + private function pausedCallIds(WorkspaceConversationMessage $record): array + { + $pending = data_get($record->approval_state, 'pending'); + + return is_array($pending) ? array_keys($pending) : []; + } + + /** + * Build the message meta payload, tucking a paused turn's raw provider blocks alongside the response meta. + * + * @return array + */ + private function messageMeta(AgentResponse $response): array + { + $meta = (array) json_decode((string) json_encode($response->meta), true); + + if (filled($blocks = $response->pausedProviderContentBlocks())) { + $meta['provider_content_blocks'] = $blocks; + } + + return $meta; + } + + /** + * @param array $attributes + */ + private function write(string $conversationId, Role $role, string $content, array $attributes = []): WorkspaceConversationMessage + { + $message = WorkspaceConversationMessage::create([ + 'workspace_conversation_id' => $conversationId, + 'role' => $role, + 'content' => $content, + ...$attributes, + ]); + + $this->touchConversation($conversationId); + + return $message; + } + + private function touchConversation(string $conversationId): void + { + WorkspaceConversation::query() + ->whereKey($conversationId) + ->update(['updated_at' => now()]); + } + + /** + * @return Builder + */ + private function messages(string $conversationId): Builder + { + return WorkspaceConversationMessage::query() + ->where('workspace_conversation_id', $conversationId); + } + + /** + * @return Builder + */ + private function pausedRows(string $conversationId): Builder + { + return $this->messages($conversationId) + ->where('role', Role::Assistant) + ->whereNotNull('approval_state'); + } +} diff --git a/app/Services/Ai/PostGenerationCardCopy.php b/app/Services/Ai/PostGenerationCardCopy.php new file mode 100644 index 000000000..92d1f3214 --- /dev/null +++ b/app/Services/Ai/PostGenerationCardCopy.php @@ -0,0 +1,68 @@ + + */ + private const KEYS = [ + 'unavailable', + 'styles_unavailable', + 'format_question', + 'style_question', + 'images_question', + 'images_none', + 'account_question', + 'topic_line', + 'posting_to', + 'brand_colors_label', + 'brand_colors_description', + 'change', + 'submit', + 'sent', + 'sentence', + 'sentence_with_brand', + 'sentence_images_none', + 'sentence_images_one', + 'sentence_images_other', + 'sentence_brand_on', + 'sentence_brand_off', + ]; + + /** + * @return array + */ + public static function forLocale(string $locale): array + { + $copy = []; + + foreach (self::KEYS as $key) { + $copy[$key] = trans("chat.post_generation.{$key}", [], $locale); + } + + return $copy; + } +} diff --git a/app/Services/Ai/PostGenerationCatalog.php b/app/Services/Ai/PostGenerationCatalog.php new file mode 100644 index 000000000..73d49416b --- /dev/null +++ b/app/Services/Ai/PostGenerationCatalog.php @@ -0,0 +1,161 @@ +}>, + * styles: list, applies_brand_visuals: bool}>, + * applies_brand_visuals_default: bool, + * } + */ + public static function forWorkspace(Workspace $workspace, ?string $locale = null): array + { + $accountsByPlatform = $workspace->socialAccounts()->active()->get() + ->groupBy(fn (SocialAccount $account): string => $account->platform->value); + + return [ + 'formats' => self::buildFormats($accountsByPlatform, $locale), + 'styles' => self::buildStyles($locale), + 'applies_brand_visuals_default' => true, + ]; + } + + /** + * Every format value the catalog can ever offer, regardless of what a + * given workspace has connected. The model's `format` argument is + * constrained to this list; whether the workspace can actually post it is + * a separate question, answered against its own catalog. + * + * @return list + */ + public static function formatValues(): array + { + return array_values(array_unique(array_map( + fn (array $entry): string => data_get($entry, 'value'), + self::formatCatalog(), + ))); + } + + /** + * The format's display name, already translated for the wizard this card + * replaces. A format the catalog gains before the copy does falls back to + * its raw value rather than showing an i18n key. + */ + private static function formatLabel(string $value, ?string $locale): string + { + $key = "posts.formats.{$value}"; + $label = trans($key, [], $locale); + + return $label === $key ? $value : $label; + } + + /** + * Each account carries its `username` and `platform` alongside the display + * label because the label alone cannot identify it: a workspace connected + * to Instagram both directly and through a Facebook Page has two accounts + * that share a display name AND a logo. The handle is what tells them + * apart in the card and in the sentence the card submits. + * + * @param Collection> $accountsByPlatform + * @return list}> + */ + private static function buildFormats(Collection $accountsByPlatform, ?string $locale): array + { + $formats = []; + + foreach (self::formatCatalog() as $entry) { + $type = data_get($entry, 'type'); + + foreach ($type->compatiblePlatforms() as $platform) { + $accounts = $accountsByPlatform->get($platform->value, collect()); + + if ($accounts->isEmpty()) { + continue; + } + + $formats[] = [ + 'value' => data_get($entry, 'value'), + 'platform' => $platform->value, + 'label' => self::formatLabel(data_get($entry, 'value'), $locale), + 'accounts' => $accounts->map(fn (SocialAccount $account): array => [ + 'id' => $account->id, + 'label' => $account->display_label, + 'username' => $account->username, + 'platform' => $platform->value, + ])->all(), + ]; + } + } + + return $formats; + } + + /** + * `ContentType::aiSupported()` paired with the `ContentType` case used + * to resolve compatible platforms, plus the Instagram-carousel + * pseudo-format. `CAROUSEL_FORMAT` is not itself a `ContentType` case — + * a carousel post is persisted as `InstagramFeed` — so it resolves + * platforms through `InstagramFeed` too. + * + * @return list + */ + private static function formatCatalog(): array + { + $entries = array_map(fn (ContentType $type): array => [ + 'value' => $type->value, + 'type' => $type, + ], ContentType::aiSupported()); + + $entries[] = ['value' => ContentType::CAROUSEL_FORMAT, 'type' => ContentType::InstagramFeed]; + + return $entries; + } + + /** + * The style list the chat's generation card renders, built from the AI + * template registry. + * + * @return list, applies_brand_visuals: bool}> + */ + private static function buildStyles(?string $locale): array + { + return array_map(fn (AiContentTemplate $template): array => [ + 'key' => $template->key(), + 'name' => trans($template->name(), [], $locale), + 'description' => trans($template->description(), [], $locale), + 'preview' => $template->previewAsset(), + 'needs_account' => $template->needsAccount(), + 'supported_formats' => $template->supportedFormats(), + 'applies_brand_visuals' => $template->appliesBrandVisuals(), + ], app(AiTemplateRegistry::class)->all()); + } +} diff --git a/app/Support/AiPromptRules.php b/app/Support/AiPromptRules.php index c8b8ba6d6..f726676e6 100644 --- a/app/Support/AiPromptRules.php +++ b/app/Support/AiPromptRules.php @@ -6,30 +6,29 @@ /** * Single source of truth for the user-supplied AI generation prompt length, - * shared by the create wizard (StartPostCreationRequest) and the editor's - * content generation (GeneratePostContentRequest). The frontend counter in - * resources/js/components/posts/create/AiPostWizard.vue mirrors these bounds. + * shared by the chat's generate_post tool and the editor's content generation + * (GeneratePostContentRequest). */ class AiPromptRules { /** - * Minimum prompt length (characters) for the create wizard. + * Minimum prompt length (characters) for AI post generation. */ public const PROMPT_MIN_LENGTH = 3; /** - * Maximum prompt length (characters); mirrored by the frontend counter. + * Maximum prompt length (characters). */ public const PROMPT_MAX_LENGTH = 2000; /** - * Validation rules for the create wizard's generation prompt. The editor's - * content generation reuses only PROMPT_MAX_LENGTH — it has no minimum, - * since it has no character counter to mirror one. + * Validation rules for the generation prompt. The editor's content + * generation reuses only PROMPT_MAX_LENGTH — it has no minimum, since it + * has no character counter to mirror one. * * @return array */ - public static function wizardPromptRule(): array + public static function generationPromptRule(): array { return ['required', 'string', 'min:'.self::PROMPT_MIN_LENGTH, 'max:'.self::PROMPT_MAX_LENGTH]; } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 8caa11623..b2af88c77 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -47,6 +47,9 @@ public function definition(): array 'utm_content' => null, 'registration_ip' => null, 'persona' => null, + 'goals' => null, + 'referral_source' => null, + 'publish_method' => null, ]; } diff --git a/database/factories/WorkspaceConversationFactory.php b/database/factories/WorkspaceConversationFactory.php new file mode 100644 index 000000000..45d063255 --- /dev/null +++ b/database/factories/WorkspaceConversationFactory.php @@ -0,0 +1,42 @@ + + */ +class WorkspaceConversationFactory extends Factory +{ + protected $model = WorkspaceConversation::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_id' => Workspace::factory(), + 'user_id' => User::factory(), + 'title' => fake()->sentence(3), + 'status' => Status::Idle, + ]; + } + + public function untitled(): static + { + return $this->state(fn (): array => ['title' => null]); + } + + public function inProgress(): static + { + return $this->state(fn (): array => ['status' => Status::InProgress]); + } +} diff --git a/database/factories/WorkspaceConversationMessageFactory.php b/database/factories/WorkspaceConversationMessageFactory.php new file mode 100644 index 000000000..5eeae57a5 --- /dev/null +++ b/database/factories/WorkspaceConversationMessageFactory.php @@ -0,0 +1,30 @@ + + */ +class WorkspaceConversationMessageFactory extends Factory +{ + protected $model = WorkspaceConversationMessage::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'workspace_conversation_id' => WorkspaceConversation::factory(), + 'role' => Role::User, + 'content' => fake()->sentence(), + ]; + } +} diff --git a/database/migrations/2026_08_18_131029_add_publish_method_to_users_table.php b/database/migrations/2026_08_18_131029_add_publish_method_to_users_table.php new file mode 100644 index 000000000..13de96f38 --- /dev/null +++ b/database/migrations/2026_08_18_131029_add_publish_method_to_users_table.php @@ -0,0 +1,24 @@ +string('publish_method')->nullable()->after('referral_source'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('publish_method'); + }); + } +}; diff --git a/database/migrations/2026_08_18_212557_create_workspace_conversations_table.php b/database/migrations/2026_08_18_212557_create_workspace_conversations_table.php new file mode 100644 index 000000000..e5626644c --- /dev/null +++ b/database/migrations/2026_08_18_212557_create_workspace_conversations_table.php @@ -0,0 +1,32 @@ +uuid('id')->primary(); + $table->uuid('workspace_id'); + $table->uuid('user_id'); + $table->string('title', 250)->nullable(); + $table->string('status')->default('idle'); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); + $table->index(['workspace_id', 'user_id', 'updated_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_conversations'); + } +}; diff --git a/database/migrations/2026_08_18_212558_create_workspace_conversation_messages_table.php b/database/migrations/2026_08_18_212558_create_workspace_conversation_messages_table.php new file mode 100644 index 000000000..d8b035cf4 --- /dev/null +++ b/database/migrations/2026_08_18_212558_create_workspace_conversation_messages_table.php @@ -0,0 +1,35 @@ +uuid('id')->primary(); + $table->uuid('workspace_conversation_id'); + $table->string('role', 25); + $table->text('content'); + $table->json('tool_calls')->nullable(); + $table->json('tool_results')->nullable(); + $table->json('usage')->nullable(); + $table->json('meta')->nullable(); + $table->json('approval_state')->nullable(); + $table->timestamps(); + + $table->foreign('workspace_conversation_id') + ->references('id')->on('workspace_conversations')->cascadeOnDelete(); + $table->index(['workspace_conversation_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('workspace_conversation_messages'); + } +}; diff --git a/database/migrations/2026_08_19_131235_add_creation_id_to_posts_table.php b/database/migrations/2026_08_19_131235_add_creation_id_to_posts_table.php new file mode 100644 index 000000000..0deb799de --- /dev/null +++ b/database/migrations/2026_08_19_131235_add_creation_id_to_posts_table.php @@ -0,0 +1,32 @@ +string('creation_id')->nullable()->after('created_via'); + $table->index('creation_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('posts', function (Blueprint $table): void { + $table->dropIndex(['creation_id']); + $table->dropColumn('creation_id'); + }); + } +}; diff --git a/database/migrations/2026_08_19_163831_add_parts_to_workspace_conversation_messages_table.php b/database/migrations/2026_08_19_163831_add_parts_to_workspace_conversation_messages_table.php new file mode 100644 index 000000000..8dc5e3a71 --- /dev/null +++ b/database/migrations/2026_08_19_163831_add_parts_to_workspace_conversation_messages_table.php @@ -0,0 +1,24 @@ +json('parts')->nullable()->after('tool_results'); + }); + } + + public function down(): void + { + Schema::table('workspace_conversation_messages', function (Blueprint $table) { + $table->dropColumn('parts'); + }); + } +}; diff --git a/herd.yml b/herd.yml new file mode 100644 index 000000000..f72b5c4ea --- /dev/null +++ b/herd.yml @@ -0,0 +1,6 @@ +integrations: + forge: + app.trypost.it: + org-slug: castellanos + server-id: 1203353 + site-id: 3188663 diff --git a/lang/ar/chat.php b/lang/ar/chat.php new file mode 100644 index 000000000..b828cefe8 --- /dev/null +++ b/lang/ar/chat.php @@ -0,0 +1,105 @@ + 'محادثة', + 'headline' => 'كيف يمكنني المساعدة في منشوراتك؟', + 'description' => 'اسأل عن المنشورات الأخيرة أو أدائها — كما لو كنت تتحدث مع MCP في TryPost، دون Claude أو ChatGPT.', + 'placeholder' => 'اسأل عن منشوراتك أو المقاييس', + 'send' => 'إرسال', + 'suggestions_label' => 'جرّب السؤال عن', + 'suggestions' => [ + 'posts' => 'المنشورات الأخيرة', + 'metrics' => 'مقاييس المنشورات', + ], + 'thinking' => 'جارٍ التفكير…', + 'groups' => [ + 'today' => 'اليوم', + 'yesterday' => 'أمس', + 'last_7_days' => 'آخر 7 أيام', + 'last_30_days' => 'آخر 30 يومًا', + 'older' => 'أقدم', + ], + 'tools' => [ + 'post_not_found' => 'لم يتم العثور على المنشور.', + 'error' => 'حدث خطأ ما. حاول مرة أخرى.', + 'publish_no_enabled_platforms' => 'ليس لهذا المنشور أي منصة مفعّلة. فعّل منصة واحدة على الأقل قبل النشر.', + 'delete_blocked' => 'تم نشر هذا المنشور بالفعل ولا يمكن حذفه من TryPost. إذا كان منشورًا على منصة لا تدعم الحذف التلقائي، مثل TikTok، فسيتعين عليك حذفه يدويًا هناك.', + 'forbidden' => 'ليس لديك إذن بإنشاء المنشورات أو تعديلها في مساحة العمل هذه. اطلب ذلك من أحد مسؤولي مساحة العمل إذا كنت بحاجة إليه.', + ], + 'approvals' => [ + 'publish' => 'هل تريد نشر هذا المنشور الآن؟ سيظهر فورًا على كل منصة مفعّلة، وبعض المنصات — مثل TikTok — لا توفر وسيلة لإلغاء النشر لاحقًا.', + 'delete_scheduled' => 'حذف هذا المنشور لا يمكن التراجع عنه. إذا كان مجدولاً للنشر، فسيؤدي هذا أيضًا إلى إلغاء ذلك. هل تريد المتابعة؟', + ], + 'errors' => [ + 'turn_in_progress' => 'لا تزال هذه المحادثة تردّ. انتظر حتى تنتهي قبل إرسال رسالة أخرى.', + 'request_failed' => 'حدث خطأ أثناء إرسال رسالتك. حاول مرة أخرى.', + 'stream_failed' => 'حدث خطأ أثناء الرد. حاول مرة أخرى.', + 'payment_required_cta' => 'إدارة الفوترة', + 'access_error' => 'هذه المحادثة غير متاحة. قد تكون محذوفة أو تخص مستخدمًا آخر.', + ], + 'tool_names' => [ + 'list_posts' => 'عرض المنشورات', + 'get_post' => 'تفاصيل المنشور', + 'get_post_metrics' => 'مقاييس المنشور', + 'create_post' => 'إنشاء منشور', + 'update_post' => 'تحديث منشور', + 'schedule_post' => 'جدولة منشور', + 'publish_post' => 'نشر منشور', + 'delete_post' => 'حذف منشور', + 'start_post_generation' => 'خيارات المنشور', + 'generate_post' => 'إنشاء منشور', + ], + 'tool_card' => [ + 'running' => 'جارٍ التنفيذ…', + 'denied' => 'لقد رفضت هذا الإجراء.', + 'unknown_tool' => 'استخدم المساعد أداة لا تملك هذه الواجهة بطاقة لها بعد.', + 'unreadable_result' => 'تعذّرت قراءة هذه النتيجة.', + 'empty_list' => 'لم يتم العثور على منشورات.', + 'open_in_editor' => 'فتح في المحرر', + 'untitled' => 'لا يوجد محتوى بعد.', + 'post_deleted' => 'تم حذف هذا المنشور.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'لم يُنشر بعد', + 'platform_not_supported' => 'المقاييس غير متاحة لهذه المنصة', + ], + 'empty' => 'لا توجد مقاييس لعرضها بعد.', + ], + 'approval' => [ + 'approve' => 'موافقة', + 'reject' => 'رفض', + 'generic_reason' => 'يحتاج هذا الإجراء إلى موافقتك قبل تنفيذه.', + ], + 'post_generation' => [ + 'unavailable' => 'لا تحتوي مساحة العمل هذه على أي حساب متصل بعد، لذا لا يوجد تنسيق لإنشاء منشور له. اربط حسابًا أولًا.', + 'styles_unavailable' => 'لا يتوفر حاليًا أي نمط للمنشور، لذا لا يوجد ما يمكن إنشاء هذا التنسيق به.', + 'format_question' => 'ما التنسيق الذي تريد أن أنشئه؟', + 'style_question' => 'ما النمط الذي سأستخدمه؟', + 'images_question' => 'كم عدد الصور التي تريدها؟', + 'images_none' => 'بدون', + 'account_question' => 'لأي حساب هذا؟', + 'topic_line' => 'حول: :topic', + 'posting_to' => 'النشر على :account', + 'brand_colors_label' => 'ألوان العلامة التجارية', + 'brand_colors_description' => 'طبّق لوحة ألوان علامتك التجارية على الصور المُنشأة.', + 'change' => 'تغيير', + 'submit' => 'إنشاء منشور', + 'sent' => 'تم إرسال الاختيارات.', + 'sentence' => 'أنشئ :format عن :topic، بنمط :style، :images، على :account.', + 'sentence_with_brand' => 'أنشئ :format عن :topic، بنمط :style، :images، على :account، :brand.', + 'sentence_images_none' => 'بدون صور', + 'sentence_images_one' => 'صورة واحدة', + 'sentence_images_other' => ':count صور', + 'sentence_brand_on' => 'بألوان العلامة التجارية', + 'sentence_brand_off' => 'بدون ألوان العلامة التجارية', + 'result_waiting' => 'جارٍ إنشاء منشورك…', + 'result_elapsed_label' => 'الوقت المنقضي: :elapsed', + 'result_waiting_hint' => 'تستمر العملية حتى لو غادرت الصفحة. سيظهر المنشور هنا بمجرد أن يصبح جاهزًا.', + 'result_detached_hint' => 'تستمر العملية في الخلفية. أعد فتح هذه المحادثة لرؤية المنشور.', + 'result_ready' => 'منشورك جاهز.', + 'result_failed' => 'تعذّر إنشاء المنشور. اطلبه مرة أخرى للمحاولة من جديد.', + ], +]; diff --git a/lang/ar/onboarding.php b/lang/ar/onboarding.php index 5c6aca6df..7d8391439 100644 --- a/lang/ar/onboarding.php +++ b/lang/ar/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => 'البدء', - 'welcome' => 'مرحبًا بك في TryPost، :name', - 'description' => 'اتبع الخطوات أدناه لمعرفة كيف يعمل TryPost ونشر منشورك الأول.', - 'skip_step' => 'تخطي هذه الخطوة', - 'continue' => 'المتابعة إلى TryPost', - 'status' => [ - 'complete' => 'مكتمل', - 'todo' => 'مطلوب', - 'skipped' => 'تم تخطيه', + 'title' => 'ما الذي يصفك بشكل أفضل؟', + 'description' => 'اختر الخيار الأقرب وسنخصص تجربتك.', + 'continue' => 'متابعة', + 'subscription_required_title' => 'في انتظار مالك الحساب', + 'subscription_required_description' => 'هذا الحساب لا يملك اشتراكًا نشطًا بعد. اطلب من مالك الحساب إكمال الدفع — ستحصل على وصول كامل فور تفعيل الاشتراك.', + 'subscription_required_owner' => 'مالك حسابك هو :name.', + 'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.', + 'progress' => 'تقدم الإعداد', + 'go_to_step' => 'الانتقال إلى الخطوة :step', + 'step_current' => 'الخطوة :step (الحالية)', + 'personas' => [ + 'creator' => 'صانع محتوى', + 'freelancer' => 'مستقل', + 'developer' => 'مطوّر', + 'startup' => 'شركة ناشئة', + 'agency' => 'وكالة', + 'small_business' => 'نشاط تجاري صغير', + 'marketer' => 'مسوّق', + 'online_store' => 'متجر إلكتروني', + 'other' => 'أخرى', ], - 'mcp' => [ - 'title' => 'اربط مساعد الذكاء الاصطناعي', - 'description' => 'أضِف TryPost كخادم MCP ليتمكّن مساعدك من إنشاء منشورات التواصل وإدارتها نيابةً عنك.', - 'copied' => 'تم نسخ رابط MCP.', + 'goals_title' => 'ما هدفك؟', + 'goals_description' => 'اختر الأنسب لك وسنقوم بإعداد TryPost من أجلك.', + 'goals' => [ + 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', + 'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي', + 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor', + 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', + 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', + 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', + 'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات', + 'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء', + 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', + 'other' => 'شيء آخر', ], - 'social' => [ - 'title' => 'اربط حسابًا اجتماعيًا', - 'description' => 'اختر شبكة واحدة واحدة على الأقل يمكن لـ TryPost النشر عليها.', - 'connected_elsewhere' => 'لقد ربطت حسابًا في مساحة عمل أخرى بالفعل، لذا اكتملت هذه الخطوة.', + 'referral_source_title' => 'كيف وجدتنا؟', + 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'دليل (G2، Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'صديق أو زميل', + 'founder' => 'المؤسس', + 'blog' => 'Blog / newsletter', + 'other' => 'شيء آخر', ], - 'first_post' => [ - 'title' => 'أنشئ منشورك الأول', - 'description' => 'جرّب هذا الموجّه مع مساعدك المتصل، أو أنشئ المنشور مباشرة في TryPost.', - 'prompt_label' => 'موجّه نموذجي', - 'sample_prompt' => 'أنشئ منشورًا اجتماعيًا ودّيًا يعرّف بعلامتي التجارية وكيّفه لكل شبكة متصلة.', - 'copy_prompt' => 'نسخ الموجّه', - 'copied' => 'تم نسخ الموجّه النموذجي.', - 'create_button' => 'إنشاء منشورك الأول', - 'or' => 'أو', + 'connect' => [ + 'title' => 'أي شبكة تستخدم أكثر؟', + 'description' => 'اختر التي تنشر عليها أكثر.', + 'follow_up' => 'اربط حساب :network.', + 'latest_post' => 'هذا أحدث منشور لديك.', + 'pitch_views' => '{0} هذا المنشور حصل على :views مشاهدة على :network.|{1} هذا المنشور حصل على :views مشاهدة على :network.|[2,*] هذا المنشور حصل على :views مشاهدة على :network.', + 'pitch_no_views' => 'هذا المنشور نُشر على :network فقط.', + 'pitch_missed' => '{0}|{1} المنشور نفسه على :first يمكن أن يضيف بسهولة :each مشاهدة. هؤلاء :extra شخصًا لم يروه.|[2,*] المنشور نفسه على :first و:second يمكن أن يضيف بسهولة :each مشاهدة على كل منهما. هؤلاء :extra شخصًا لم يروه.', + 'pitch_sales' => 'قد تكون تخسر آلاف المبيعات.', + 'change_network' => 'غيّر الشبكة', + 'required' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل للمتابعة.', ], - 'ready' => [ - 'title' => 'أنت جاهز للنشر', - 'description' => 'كل شيء جاهز. تابع إلى TryPost وابدأ بتخطيط محتواك.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/ar/posts.php b/lang/ar/posts.php index 77c703250..0720c7623 100644 --- a/lang/ar/posts.php +++ b/lang/ar/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'إلغاء', ], - 'create' => [ - 'title' => 'إنشاء منشور جديد', - 'description' => 'اختر كيف تريد البدء.', - 'scratch_title' => 'البدء من الصفر', - 'scratch_description' => 'افتح منشورًا فارغًا واكتب كل شيء بنفسك.', - 'ai_title' => 'الإنشاء بالذكاء الاصطناعي', - 'ai_description' => 'صِف ما تريده وينشئ الذكاء الاصطناعي المحتوى من أجلك.', - 'ai_configure_description' => 'اختر تنسيقًا وصِف المنشور الذي تريد إنشاءه.', - 'ai_pick_template_description' => 'اختر نمطًا لمنشورك المُنشأ بالذكاء الاصطناعي.', - 'coming_soon' => 'قريبًا', - - 'preview' => [ - 'image_title' => 'عنوان الصورة', - 'image_body' => 'نص الصورة', - ], - - 'steps' => [ - 'template_picker_title' => 'اختر نمطًا', - 'format_title' => 'اختر تنسيقًا', - 'format_description' => 'اختر نوع المنشور الذي تريد إنشاءه.', - 'account_title' => 'اختر حسابًا', - 'account_description' => 'اختر الحساب الاجتماعي للنشر إليه.', - 'media_title' => 'خيارات الوسائط', - 'media_carousel' => 'كم عدد الشرائح؟', - 'media_optional' => 'تضمين صور؟', - 'media_optional_label' => 'كم عدد الصور؟', - 'media_none' => 'بلا', - 'media_count_label' => 'عدد الصور', - 'brand_colors_label' => 'ألوان العلامة التجارية', - 'brand_colors_description' => 'طبّق لوحة ألوان علامتك التجارية على الصور المُنشأة. عطّله لترك الذكاء الاصطناعي يختار الألوان التي تناسب المحتوى.', - 'prompt_title' => 'صِف منشورك', - 'prompt_label' => 'ما موضوع هذا المنشور؟', - 'prompt_placeholder' => 'مثال: الإعلان عن ميزة العرض الدائري الجديدة لدينا على Instagram', - 'preview_error' => 'حدث خطأ ما. يرجى المحاولة مرة أخرى.', - 'loading_page_title' => 'جارٍ إنشاء منشورك', - 'loading_eta' => 'الوقت المقدّر: حوالي :minutes.', - 'loading_eta_minute_one' => 'دقيقة واحدة', - 'loading_eta_minute_other' => ':count دقيقة', - 'loading_leave_title' => 'يمكنك متابعة عملك.', - 'loading_leave_body' => 'سنُعلمك عندما يصبح المنشور جاهزًا.', - 'loading_leave_cta' => 'الذهاب إلى التقويم', - 'loading_create_another_cta' => 'إنشاء منشور آخر', - 'loading_tip_credits' => 'تستخدم كل صورة بالذكاء الاصطناعي حوالي 15 من الرصيد.', - 'loading_tip_edit' => 'ستتمكن من تعديل كل شيء بمجرد أن يصبح المنشور جاهزًا.', - 'loading_tip_draft' => 'تصل المنشورات المُنشأة إلى مسوداتك.', - 'loading_tip_brand' => 'عدّل إعدادات علامتك التجارية للتأثير في المنشورات المستقبلية.', - 'loading_tip_carousel' => 'تقدّم العروض الدائرية شريحة واحدة لكل صورة مرفوعة.', - 'loading_tip_quality' => 'تم ضبط جودة الصورة لتحقيق توازن بين السرعة والتكلفة.', - 'create' => 'إنشاء منشور', - 'back' => 'رجوع', - 'next' => 'متابعة', - 'cancel' => 'إلغاء', - 'discard' => 'تجاهل', - 'retry' => 'إعادة المحاولة', - 'no_platforms' => 'لا توجد حسابات متصلة', - 'connect_first' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل لاستخدام الإنشاء بالذكاء الاصطناعي.', - 'no_account_for_template' => 'اربط حسابًا متوافقًا لاستخدام هذا القالب.', - - 'format' => [ - 'instagram_feed' => 'منشور خلاصة Instagram', - 'instagram_carousel' => 'عرض Instagram الدائري', - 'linkedin_post' => 'منشور LinkedIn', - 'linkedin_page_post' => 'منشور صفحة LinkedIn', - 'x_post' => 'منشور X', - 'bluesky_post' => 'منشور Bluesky', - 'threads_post' => 'منشور Threads', - 'mastodon_post' => 'منشور Mastodon', - 'telegram_post' => 'منشور Telegram', - 'discord_message' => 'رسالة Discord', - 'facebook_post' => 'منشور Facebook', - 'pinterest_pin' => 'دبوس Pinterest', - 'instagram_story' => 'قصة Instagram', - 'facebook_story' => 'قصة Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'منشور خلاصة Instagram', + 'instagram_carousel' => 'عرض Instagram الدائري', + 'linkedin_post' => 'منشور LinkedIn', + 'linkedin_page_post' => 'منشور صفحة LinkedIn', + 'x_post' => 'منشور X', + 'bluesky_post' => 'منشور Bluesky', + 'threads_post' => 'منشور Threads', + 'mastodon_post' => 'منشور Mastodon', + 'facebook_post' => 'منشور Facebook', + 'pinterest_pin' => 'دبوس Pinterest', + 'instagram_story' => 'قصة Instagram', ], ]; diff --git a/lang/ar/sidebar.php b/lang/ar/sidebar.php index c3d592ece..4e8e6e6df 100644 --- a/lang/ar/sidebar.php +++ b/lang/ar/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'التحليلات', 'automations' => 'الأتمتة', - 'onboarding' => 'البدء', - 'onboarding_hint' => 'أكمل الإعداد', + 'browse' => 'تصفح', + 'chat' => 'محادثة', + 'chat_history' => 'سجل المحادثات', + 'new_chat' => 'محادثة جديدة', + 'no_chats' => 'لا توجد محادثات بعد', + 'last_7_days' => 'آخر 7 أيام', + 'last_30_days' => 'آخر 30 يومًا', + 'older' => 'أقدم', 'posts' => [ 'calendar' => 'التقويم', 'all' => 'الكل', diff --git a/lang/ar/welcome.php b/lang/ar/welcome.php deleted file mode 100644 index 9b64c5b3c..000000000 --- a/lang/ar/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'ما الذي يصفك بشكل أفضل؟', - 'description' => 'اختر الخيار الأقرب وسنخصص تجربتك.', - 'continue' => 'متابعة', - 'subscription_required_title' => 'في انتظار مالك الحساب', - 'subscription_required_description' => 'هذا الحساب لا يملك اشتراكًا نشطًا بعد. اطلب من مالك الحساب إكمال الدفع — ستحصل على وصول كامل فور تفعيل الاشتراك.', - 'subscription_required_owner' => 'مالك حسابك هو :name.', - 'subscription_required_auto' => 'يتم تحديث هذه الصفحة تلقائيًا — لا حاجة لإعادة التحميل.', - 'progress' => 'تقدم الترحيب', - 'go_to_step' => 'الانتقال إلى الخطوة :step', - 'step_current' => 'الخطوة :step (الحالية)', - 'personas' => [ - 'creator' => 'صانع محتوى', - 'freelancer' => 'مستقل', - 'developer' => 'مطوّر', - 'startup' => 'شركة ناشئة', - 'agency' => 'وكالة', - 'small_business' => 'نشاط تجاري صغير', - 'marketer' => 'مسوّق', - 'online_store' => 'متجر إلكتروني', - 'other' => 'أخرى', - ], - 'goals_title' => 'ما هدفك؟', - 'goals_description' => 'اختر كل ما يناسبك وسنقوم بإعداد TryPost من أجلك.', - 'goals' => [ - 'save_time' => 'توفير الوقت بالنشر في كل مكان دفعة واحدة', - 'ai_content' => 'إنشاء منشورات بذكاء TryPost الاصطناعي', - 'use_mcp' => 'إنشاء منشورات عبر Claude أو ChatGPT أو Cursor', - 'plan_calendar' => 'التخطيط لمنشوراتي على التقويم', - 'stay_on_brand' => 'الحفاظ على اتساق كل منشور مع العلامة التجارية', - 'grow_audience' => 'تنمية جمهوري وزيادة التفاعل', - 'drive_sales' => 'الحصول على المزيد من الزيارات والمبيعات', - 'manage_clients' => 'إدارة عدة علامات تجارية أو عملاء', - 'just_exploring' => 'مجرد استكشاف في الوقت الحالي', - 'other' => 'شيء آخر', - ], - 'referral_source_title' => 'كيف وجدتنا؟', - 'referral_source_description' => 'يساعدنا هذا على فهم كيفية اكتشاف الأشخاص لـ TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'دليل (G2، Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'صديق أو زميل', - 'founder' => 'المؤسس', - 'blog' => 'Blog / newsletter', - 'other' => 'شيء آخر', - ], - 'connect' => [ - 'title' => 'اربط حسابًا اجتماعيًا', - 'description' => 'اختر شبكة واحدة على الأقل يمكن لـ TryPost النشر عليها.', - 'required' => 'اربط حسابًا اجتماعيًا واحدًا على الأقل للمتابعة.', - ], -]; diff --git a/lang/de/chat.php b/lang/de/chat.php new file mode 100644 index 000000000..dea533f1a --- /dev/null +++ b/lang/de/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'Womit kann ich bei deinen Posts helfen?', + 'description' => 'Frag nach aktuellen Posts oder ihrer Performance — wie mit dem TryPost-MCP, ohne Claude oder ChatGPT.', + 'placeholder' => 'Frag nach Posts oder Kennzahlen', + 'send' => 'Senden', + 'suggestions_label' => 'Frag zum Beispiel nach', + 'suggestions' => [ + 'posts' => 'Aktuelle Posts', + 'metrics' => 'Post-Kennzahlen', + ], + 'thinking' => 'Denkt nach…', + 'groups' => [ + 'today' => 'Heute', + 'yesterday' => 'Gestern', + 'last_7_days' => 'Letzte 7 Tage', + 'last_30_days' => 'Letzte 30 Tage', + 'older' => 'Älter', + ], + 'tools' => [ + 'post_not_found' => 'Beitrag nicht gefunden.', + 'error' => 'Etwas ist schiefgelaufen. Bitte versuche es erneut.', + 'publish_no_enabled_platforms' => 'Dieser Beitrag hat keine aktivierten Plattformen. Aktiviere mindestens eine, bevor du veröffentlichst.', + 'delete_blocked' => 'Dieser Beitrag wurde bereits veröffentlicht und kann nicht aus TryPost gelöscht werden. Ist er auf einer Plattform live, die keine automatische Entfernung unterstützt, etwa TikTok, musst du ihn dort manuell löschen.', + 'forbidden' => 'Du hast keine Berechtigung, in diesem Workspace Beiträge zu erstellen oder zu ändern. Frag bei Bedarf eine Workspace-Administratorin oder einen Workspace-Administrator.', + ], + 'approvals' => [ + 'publish' => 'Diesen Beitrag jetzt veröffentlichen? Er geht sofort auf allen aktivierten Plattformen live, und manche — wie TikTok — bieten danach keine Möglichkeit, ihn wieder zu entfernen.', + 'delete_scheduled' => 'Das Löschen dieses Beitrags lässt sich nicht rückgängig machen. Ist er zur Veröffentlichung geplant, wird dadurch auch das abgebrochen. Trotzdem löschen?', + ], + 'errors' => [ + 'turn_in_progress' => 'Diese Unterhaltung antwortet noch. Warte, bis sie fertig ist, bevor du eine weitere Nachricht sendest.', + 'request_failed' => 'Beim Senden deiner Nachricht ist etwas schiefgelaufen. Bitte versuche es erneut.', + 'stream_failed' => 'Beim Antworten ist etwas schiefgelaufen. Bitte versuche es erneut.', + 'payment_required_cta' => 'Abrechnung verwalten', + 'access_error' => 'Diese Unterhaltung ist nicht verfügbar. Sie wurde möglicherweise gelöscht oder gehört einer anderen Person.', + ], + 'tool_names' => [ + 'list_posts' => 'Posts auflisten', + 'get_post' => 'Post-Details', + 'get_post_metrics' => 'Post-Kennzahlen', + 'create_post' => 'Post erstellen', + 'update_post' => 'Post aktualisieren', + 'schedule_post' => 'Post planen', + 'publish_post' => 'Post veröffentlichen', + 'delete_post' => 'Post löschen', + 'start_post_generation' => 'Beitragsoptionen', + 'generate_post' => 'Beitrag generieren', + ], + 'tool_card' => [ + 'running' => 'Wird ausgeführt …', + 'denied' => 'Du hast diese Aktion abgelehnt.', + 'unknown_tool' => 'Der Assistent hat ein Tool verwendet, für das diese Oberfläche noch keine Karte hat.', + 'unreadable_result' => 'Dieses Ergebnis konnte nicht gelesen werden.', + 'empty_list' => 'Keine Posts gefunden.', + 'open_in_editor' => 'Im Editor öffnen', + 'untitled' => 'Noch kein Inhalt.', + 'post_deleted' => 'Dieser Beitrag wurde gelöscht.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Noch nicht veröffentlicht', + 'platform_not_supported' => 'Für diese Plattform sind keine Kennzahlen verfügbar', + ], + 'empty' => 'Noch keine Kennzahlen vorhanden.', + ], + 'approval' => [ + 'approve' => 'Genehmigen', + 'reject' => 'Ablehnen', + 'generic_reason' => 'Diese Aktion braucht deine Zustimmung, bevor sie ausgeführt wird.', + ], + 'post_generation' => [ + 'unavailable' => 'Dieser Workspace hat noch kein verbundenes Konto, deshalb gibt es kein Format, für das ein Beitrag generiert werden könnte. Verbinde zuerst ein Konto.', + 'styles_unavailable' => 'Zurzeit ist kein Beitragsstil verfügbar, deshalb gibt es nichts, womit dieses Format generiert werden könnte.', + 'format_question' => 'Welches Format soll ich erstellen?', + 'style_question' => 'Welchen Stil soll es haben?', + 'images_question' => 'Wie viele Bilder sollen es sein?', + 'images_none' => 'Keine', + 'account_question' => 'Für welches Konto ist das?', + 'topic_line' => 'Thema: :topic', + 'posting_to' => 'Wird auf :account veröffentlicht', + 'brand_colors_label' => 'Markenfarben', + 'brand_colors_description' => 'Wende die Markenpalette deines Workspace auf die generierten Bilder an.', + 'change' => 'Ändern', + 'submit' => 'Beitrag generieren', + 'sent' => 'Auswahl gesendet.', + 'sentence' => 'Generiere :format über :topic, Stil :style, :images, für :account.', + 'sentence_with_brand' => 'Generiere :format über :topic, Stil :style, :images, für :account, :brand.', + 'sentence_images_none' => 'ohne Bilder', + 'sentence_images_one' => '1 Bild', + 'sentence_images_other' => ':count Bilder', + 'sentence_brand_on' => 'mit Markenfarben', + 'sentence_brand_off' => 'ohne Markenfarben', + 'result_waiting' => 'Dein Beitrag wird generiert…', + 'result_elapsed_label' => 'Vergangene Zeit: :elapsed', + 'result_waiting_hint' => 'Das läuft weiter, auch wenn du die Seite verlässt. Der Beitrag erscheint hier, sobald er fertig ist.', + 'result_detached_hint' => 'Das läuft im Hintergrund weiter. Öffne diese Unterhaltung erneut, um den Beitrag zu sehen.', + 'result_ready' => 'Dein Beitrag ist fertig.', + 'result_failed' => 'Der Beitrag konnte nicht generiert werden. Frag noch einmal, um es erneut zu versuchen.', + ], +]; diff --git a/lang/de/onboarding.php b/lang/de/onboarding.php index 1e91ebd02..363ab4497 100644 --- a/lang/de/onboarding.php +++ b/lang/de/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'Was beschreibt dich am besten?', + 'description' => 'Wähle die passende Option, und wir passen dein Erlebnis an.', + 'continue' => 'Weiter', + 'subscription_required_title' => 'Warten auf den Kontoinhaber', + 'subscription_required_description' => 'Dieses Konto hat noch kein aktives Abo. Bitte den Kontoinhaber, den Checkout abzuschließen — du erhältst vollen Zugriff, sobald es aktiv ist.', + 'subscription_required_owner' => 'Der Kontoinhaber ist :name.', + 'subscription_required_auto' => 'Diese Seite aktualisiert sich automatisch — kein Neuladen nötig.', + 'progress' => 'Onboarding-Fortschritt', + 'go_to_step' => 'Zu Schritt :step gehen', + 'step_current' => 'Schritt :step (aktuell)', + 'personas' => [ + 'creator' => 'Content Creator', + 'freelancer' => 'Freelancer', + 'developer' => 'Entwickler', + 'startup' => 'Startup', + 'agency' => 'Agentur', + 'small_business' => 'Kleinunternehmen', + 'marketer' => 'Marketer', + 'online_store' => 'Onlineshop', + 'other' => 'Sonstiges', ], - '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.', + 'goals_title' => 'Was ist dein Ziel?', + 'goals_description' => 'Wähle die Option, die am besten passt, und wir richten TryPost für dich ein.', + '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', + 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', + 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', + 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', + 'drive_sales' => 'Mehr Traffic und Verkäufe erzielen', + 'manage_clients' => 'Mehrere Marken oder Kunden verwalten', + 'just_exploring' => 'Ich schaue mich vorerst nur um', + 'other' => 'Etwas anderes', ], - '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.', + 'referral_source_title' => 'Wie hast du uns gefunden?', + 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Verzeichnis (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Freund oder Kollege', + 'founder' => 'Gründer', + 'blog' => 'Blog / newsletter', + 'other' => 'Etwas anderes', ], - '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', + 'connect' => [ + 'title' => 'Welches Netzwerk nutzt du am meisten?', + 'description' => 'Wähle das, auf dem du am meisten postest.', + 'follow_up' => 'Verbinde dein :network-Konto.', + 'latest_post' => 'Das ist dein neuester Beitrag.', + 'pitch_views' => '{0} Dieser Beitrag hatte :views Views auf :network.|{1} Dieser Beitrag hatte :views View auf :network.|[2,*] Dieser Beitrag hatte :views Views auf :network.', + 'pitch_no_views' => 'Dieser Beitrag lief nur auf :network.', + 'pitch_missed' => '{0}|{1} Derselbe Beitrag auf :first könnte leicht noch :each Views holen. Das sind :extra Leute, die ihn nie gesehen haben.|[2,*] Derselbe Beitrag auf :first und :second könnte leicht noch :each Views auf jedem holen. Das sind :extra Leute, die ihn nie gesehen haben.', + 'pitch_sales' => 'Du lässt vielleicht Tausende an Umsatz liegen.', + 'change_network' => 'Netzwerk wechseln', + 'required' => 'Verbinde mindestens ein soziales Konto, um fortzufahren.', ], - 'ready' => [ - 'title' => 'Du bist bereit zum Veröffentlichen', - 'description' => 'Alles klar. Weiter zu TryPost und plane deine Inhalte.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/de/posts.php b/lang/de/posts.php index a082a28d3..89bab4e9d 100644 --- a/lang/de/posts.php +++ b/lang/de/posts.php @@ -593,80 +593,18 @@ 'cancel' => 'Abbrechen', ], - 'create' => [ - 'title' => 'Neuen Beitrag erstellen', - 'description' => 'Wähle, wie du beginnen möchtest.', - 'scratch_title' => 'Von Grund auf beginnen', - 'scratch_description' => 'Öffne einen leeren Beitrag und schreibe alles selbst.', - 'ai_title' => 'Mit KI generieren', - 'ai_description' => 'Beschreibe, was du möchtest, und die KI generiert den Inhalt für dich.', - 'ai_configure_description' => 'Wähle ein Format und beschreibe den Beitrag, den du erstellen möchtest.', - 'ai_pick_template_description' => 'Wähle einen Stil für deinen KI-generierten Beitrag.', - 'coming_soon' => 'Demnächst verfügbar', - - 'preview' => [ - 'image_title' => 'Bildtitel', - 'image_body' => 'Bildtext', - ], - - 'steps' => [ - 'template_picker_title' => 'Wähle einen Stil', - 'format_title' => 'Wähle ein Format', - 'format_description' => 'Wähle die Art des Beitrags, den du erstellen möchtest.', - 'account_title' => 'Wähle ein Konto', - 'account_description' => 'Wähle das Social-Media-Konto zum Veröffentlichen.', - 'media_title' => 'Medienoptionen', - 'media_carousel' => 'Wie viele Slides?', - 'media_optional' => 'Bilder einbeziehen?', - 'media_optional_label' => 'Wie viele Bilder?', - 'media_none' => 'Keine', - 'media_count_label' => 'Anzahl der Bilder', - 'brand_colors_label' => 'Markenfarben', - 'brand_colors_description' => 'Wende die Markenpalette deines Workspace auf die generierten Bilder an. Deaktiviere die Option, damit die KI passende Farben zum Inhalt wählt.', - 'prompt_title' => 'Beschreibe deinen Beitrag', - 'prompt_label' => 'Worum geht es in diesem Beitrag?', - 'prompt_placeholder' => 'z. B. Unser neues Karussell-Feature für Instagram ankündigen', - 'preview_error' => 'Etwas ist schiefgelaufen. Bitte versuche es erneut.', - 'loading_page_title' => 'Dein Beitrag wird generiert', - 'loading_eta' => 'Geschätzte Zeit: etwa :minutes.', - 'loading_eta_minute_one' => '1 Minute', - 'loading_eta_minute_other' => ':count Minuten', - 'loading_leave_title' => 'Du kannst weiterarbeiten.', - 'loading_leave_body' => 'Wir benachrichtigen dich, wenn der Beitrag fertig ist.', - 'loading_leave_cta' => 'Zum Kalender', - 'loading_create_another_cta' => 'Weiteren Beitrag erstellen', - 'loading_tip_credits' => 'Jedes KI-Bild verbraucht etwa 15 Credits.', - 'loading_tip_edit' => 'Du kannst alles bearbeiten, sobald der Beitrag fertig ist.', - 'loading_tip_draft' => 'Generierte Beiträge landen in deinen Entwürfen.', - 'loading_tip_brand' => 'Passe deine Markeneinstellungen an, um zukünftige Beiträge zu beeinflussen.', - 'loading_tip_carousel' => 'Karussells liefern pro hochgeladenem Bild ein Slide.', - 'loading_tip_quality' => 'Die Bildqualität ist auf ein Gleichgewicht aus Geschwindigkeit und Kosten eingestellt.', - 'create' => 'Beitrag erstellen', - 'back' => 'Zurück', - 'next' => 'Weiter', - 'cancel' => 'Abbrechen', - 'discard' => 'Verwerfen', - 'retry' => 'Erneut versuchen', - 'no_platforms' => 'Keine verbundenen Konten', - 'connect_first' => 'Verbinde mindestens ein Social-Media-Konto, um die KI-Generierung zu nutzen.', - 'no_account_for_template' => 'Verbinde ein kompatibles Konto, um diese Vorlage zu verwenden.', - - 'format' => [ - 'instagram_feed' => 'Instagram-Feed-Beitrag', - 'instagram_carousel' => 'Instagram-Karussell', - 'linkedin_post' => 'LinkedIn-Beitrag', - 'linkedin_page_post' => 'LinkedIn-Seiten-Beitrag', - 'x_post' => 'X-Beitrag', - 'bluesky_post' => 'Bluesky-Beitrag', - 'threads_post' => 'Threads-Beitrag', - 'mastodon_post' => 'Mastodon-Beitrag', - 'telegram_post' => 'Telegram-Beitrag', - 'discord_message' => 'Discord-Nachricht', - 'facebook_post' => 'Facebook-Beitrag', - 'pinterest_pin' => 'Pinterest-Pin', - 'instagram_story' => 'Instagram-Story', - 'facebook_story' => 'Facebook-Story', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram-Feed-Beitrag', + 'instagram_carousel' => 'Instagram-Karussell', + 'linkedin_post' => 'LinkedIn-Beitrag', + 'linkedin_page_post' => 'LinkedIn-Seiten-Beitrag', + 'x_post' => 'X-Beitrag', + 'bluesky_post' => 'Bluesky-Beitrag', + 'threads_post' => 'Threads-Beitrag', + 'mastodon_post' => 'Mastodon-Beitrag', + 'facebook_post' => 'Facebook-Beitrag', + 'pinterest_pin' => 'Pinterest-Pin', + 'instagram_story' => 'Instagram-Story', ], ]; diff --git a/lang/de/sidebar.php b/lang/de/sidebar.php index 06c41f1c3..637e79f71 100644 --- a/lang/de/sidebar.php +++ b/lang/de/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analytics', 'automations' => 'Automatisierungen', - 'onboarding' => 'Erste Schritte', - 'onboarding_hint' => 'Einrichtung abschließen', + 'browse' => 'Menü', + 'chat' => 'Chat', + 'chat_history' => 'Chatverlauf', + 'new_chat' => 'Neuer Chat', + 'no_chats' => 'Noch keine Chats', + 'last_7_days' => 'Letzte 7 Tage', + 'last_30_days' => 'Letzte 30 Tage', + 'older' => 'Älter', 'posts' => [ 'calendar' => 'Kalender', 'all' => 'Alle', diff --git a/lang/de/welcome.php b/lang/de/welcome.php deleted file mode 100644 index 4fbdf7264..000000000 --- a/lang/de/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Was beschreibt dich am besten?', - 'description' => 'Wähle die passende Option, und wir passen dein Erlebnis an.', - 'continue' => 'Weiter', - 'subscription_required_title' => 'Warten auf den Kontoinhaber', - 'subscription_required_description' => 'Dieses Konto hat noch kein aktives Abo. Bitte den Kontoinhaber, den Checkout abzuschließen — du erhältst vollen Zugriff, sobald es aktiv ist.', - '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)', - 'personas' => [ - 'creator' => 'Content Creator', - 'freelancer' => 'Freelancer', - 'developer' => 'Entwickler', - 'startup' => 'Startup', - 'agency' => 'Agentur', - 'small_business' => 'Kleinunternehmen', - 'marketer' => 'Marketer', - 'online_store' => 'Onlineshop', - 'other' => 'Sonstiges', - ], - 'goals_title' => 'Was ist dein Ziel?', - 'goals_description' => 'Wähle alles aus, was passt, und wir richten TryPost für dich ein.', - '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', - 'plan_calendar' => 'Meine Beiträge in einem Kalender planen', - 'stay_on_brand' => 'Jeden Beitrag markenkonform halten', - 'grow_audience' => 'Meine Reichweite und mein Engagement steigern', - 'drive_sales' => 'Mehr Traffic und Verkäufe erzielen', - 'manage_clients' => 'Mehrere Marken oder Kunden verwalten', - 'just_exploring' => 'Ich schaue mich vorerst nur um', - 'other' => 'Etwas anderes', - ], - 'referral_source_title' => 'Wie hast du uns gefunden?', - 'referral_source_description' => 'Das hilft uns zu verstehen, wie Menschen TryPost entdecken.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Verzeichnis (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Freund oder Kollege', - 'founder' => 'Gründer', - 'blog' => 'Blog / newsletter', - 'other' => 'Etwas anderes', - ], - 'connect' => [ - 'title' => 'Verbinde ein soziales Konto', - 'description' => 'Wähle mindestens ein Netzwerk, auf dem TryPost deine Inhalte veröffentlichen kann.', - 'required' => 'Verbinde mindestens ein soziales Konto, um fortzufahren.', - ], -]; diff --git a/lang/el/chat.php b/lang/el/chat.php new file mode 100644 index 000000000..69ff970db --- /dev/null +++ b/lang/el/chat.php @@ -0,0 +1,105 @@ + 'Συνομιλία', + 'headline' => 'Πώς μπορώ να βοηθήσω με τις αναρτήσεις σας;', + 'description' => 'Ρωτήστε για πρόσφατες αναρτήσεις ή την απόδοσή τους — σαν να μιλάτε στο MCP του TryPost, χωρίς Claude ή ChatGPT.', + 'placeholder' => 'Ρωτήστε για αναρτήσεις ή μετρήσεις', + 'send' => 'Αποστολή', + 'suggestions_label' => 'Δοκιμάστε να ρωτήσετε για', + 'suggestions' => [ + 'posts' => 'Πρόσφατες αναρτήσεις', + 'metrics' => 'Μετρήσεις αναρτήσεων', + ], + 'thinking' => 'Σκέφτεται…', + 'groups' => [ + 'today' => 'Σήμερα', + 'yesterday' => 'Χθες', + 'last_7_days' => 'Τελευταίες 7 ημέρες', + 'last_30_days' => 'Τελευταίες 30 ημέρες', + 'older' => 'Παλαιότερα', + ], + 'tools' => [ + 'post_not_found' => 'Η ανάρτηση δεν βρέθηκε.', + 'error' => 'Κάτι πήγε στραβά. Δοκιμάστε ξανά.', + 'publish_no_enabled_platforms' => 'Αυτή η ανάρτηση δεν έχει ενεργοποιημένες πλατφόρμες. Ενεργοποιήστε τουλάχιστον μία πριν τη δημοσίευση.', + 'delete_blocked' => 'Αυτή η ανάρτηση έχει ήδη δημοσιευτεί και δεν μπορεί να διαγραφεί από το TryPost. Αν είναι ενεργή σε πλατφόρμα που δεν υποστηρίζει αυτόματη αφαίρεση, όπως το TikTok, θα πρέπει να τη διαγράψετε εκεί χειροκίνητα.', + 'forbidden' => 'Δεν έχετε δικαίωμα να δημιουργείτε ή να αλλάζετε αναρτήσεις σε αυτόν τον χώρο εργασίας. Ζητήστε το από έναν διαχειριστή του χώρου εργασίας αν το χρειάζεστε.', + ], + 'approvals' => [ + 'publish' => 'Δημοσίευση αυτής της ανάρτησης τώρα; Θα είναι αμέσως ενεργή σε κάθε ενεργοποιημένη πλατφόρμα, και ορισμένες πλατφόρμες — όπως το TikTok — δεν προσφέρουν τρόπο κατάργησης δημοσίευσης αργότερα.', + 'delete_scheduled' => 'Η διαγραφή αυτής της ανάρτησης δεν μπορεί να αναιρεθεί. Αν είναι προγραμματισμένη για δημοσίευση, αυτό θα την ακυρώσει επίσης. Διαγραφή ούτως ή άλλως;', + ], + 'errors' => [ + 'turn_in_progress' => 'Αυτή η συνομιλία απαντά ακόμη. Περιμένετε να ολοκληρωθεί πριν στείλετε άλλο μήνυμα.', + 'request_failed' => 'Κάτι πήγε στραβά κατά την αποστολή του μηνύματός σας. Δοκιμάστε ξανά.', + 'stream_failed' => 'Κάτι πήγε στραβά κατά την απάντηση. Δοκιμάστε ξανά.', + 'payment_required_cta' => 'Διαχείριση χρέωσης', + 'access_error' => 'Αυτή η συνομιλία δεν είναι διαθέσιμη. Ίσως διαγράφηκε ή ανήκει σε άλλο χρήστη.', + ], + 'tool_names' => [ + 'list_posts' => 'Λίστα αναρτήσεων', + 'get_post' => 'Λεπτομέρειες ανάρτησης', + 'get_post_metrics' => 'Μετρήσεις ανάρτησης', + 'create_post' => 'Δημιουργία ανάρτησης', + 'update_post' => 'Ενημέρωση ανάρτησης', + 'schedule_post' => 'Προγραμματισμός ανάρτησης', + 'publish_post' => 'Δημοσίευση ανάρτησης', + 'delete_post' => 'Διαγραφή ανάρτησης', + 'start_post_generation' => 'Επιλογές ανάρτησης', + 'generate_post' => 'Δημιουργία ανάρτησης', + ], + 'tool_card' => [ + 'running' => 'Εκτελείται…', + 'denied' => 'Απορρίψατε αυτή την ενέργεια.', + 'unknown_tool' => 'Ο βοηθός χρησιμοποίησε ένα εργαλείο για το οποίο αυτή η διεπαφή δεν έχει ακόμη κάρτα.', + 'unreadable_result' => 'Αυτό το αποτέλεσμα δεν ήταν δυνατό να διαβαστεί.', + 'empty_list' => 'Δεν βρέθηκαν αναρτήσεις.', + 'open_in_editor' => 'Άνοιγμα στον επεξεργαστή', + 'untitled' => 'Χωρίς περιεχόμενο ακόμη.', + 'post_deleted' => 'Αυτή η ανάρτηση διαγράφηκε.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Δεν έχει δημοσιευτεί ακόμη', + 'platform_not_supported' => 'Δεν υπάρχουν διαθέσιμες μετρήσεις για αυτή την πλατφόρμα', + ], + 'empty' => 'Δεν υπάρχουν ακόμη μετρήσεις για εμφάνιση.', + ], + 'approval' => [ + 'approve' => 'Έγκριση', + 'reject' => 'Απόρριψη', + 'generic_reason' => 'Αυτή η ενέργεια χρειάζεται την έγκρισή σας πριν εκτελεστεί.', + ], + 'post_generation' => [ + 'unavailable' => 'Αυτός ο χώρος εργασίας δεν έχει ακόμη συνδεδεμένο λογαριασμό, οπότε δεν υπάρχει μορφή για να δημιουργηθεί ανάρτηση. Σύνδεσε πρώτα έναν λογαριασμό.', + 'styles_unavailable' => 'Αυτή τη στιγμή δεν υπάρχει διαθέσιμο στυλ ανάρτησης, οπότε δεν υπάρχει τίποτα για να δημιουργηθεί αυτή η μορφή.', + 'format_question' => 'Ποια μορφή να δημιουργήσω;', + 'style_question' => 'Ποιο στυλ να χρησιμοποιήσω;', + 'images_question' => 'Πόσες εικόνες θέλεις;', + 'images_none' => 'Καμία', + 'account_question' => 'Για ποιον λογαριασμό είναι;', + 'topic_line' => 'Σχετικά με: :topic', + 'posting_to' => 'Δημοσίευση στο :account', + 'brand_colors_label' => 'Χρώματα μάρκας', + 'brand_colors_description' => 'Εφάρμοσε την παλέτα της μάρκας σου στις εικόνες που δημιουργούνται.', + 'change' => 'Αλλαγή', + 'submit' => 'Δημιουργία ανάρτησης', + 'sent' => 'Οι επιλογές στάλθηκαν.', + 'sentence' => 'Δημιούργησε :format για :topic, στυλ :style, :images, στο :account.', + 'sentence_with_brand' => 'Δημιούργησε :format για :topic, στυλ :style, :images, στο :account, :brand.', + 'sentence_images_none' => 'χωρίς εικόνες', + 'sentence_images_one' => '1 εικόνα', + 'sentence_images_other' => ':count εικόνες', + 'sentence_brand_on' => 'με τα χρώματα της μάρκας', + 'sentence_brand_off' => 'χωρίς τα χρώματα της μάρκας', + 'result_waiting' => 'Δημιουργώ την ανάρτησή σου…', + 'result_elapsed_label' => 'Χρόνος που πέρασε: :elapsed', + 'result_waiting_hint' => 'Συνεχίζει να τρέχει ακόμη κι αν φύγεις από τη σελίδα. Η ανάρτηση θα εμφανιστεί εδώ μόλις είναι έτοιμη.', + 'result_detached_hint' => 'Συνεχίζει να τρέχει στο παρασκήνιο. Άνοιξε ξανά αυτή τη συνομιλία για να δεις την ανάρτηση.', + 'result_ready' => 'Η ανάρτησή σου είναι έτοιμη.', + 'result_failed' => 'Δεν ήταν δυνατή η δημιουργία της ανάρτησης. Ζήτησέ την ξανά για νέα προσπάθεια.', + ], +]; diff --git a/lang/el/onboarding.php b/lang/el/onboarding.php index be6f33191..0a2367744 100644 --- a/lang/el/onboarding.php +++ b/lang/el/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => 'Ξεκινώντας', - 'welcome' => 'Καλώς ήρθες στο TryPost, :name', - 'description' => 'Ακολούθησε τα παρακάτω βήματα για να δεις πώς λειτουργεί το TryPost και να δημοσιεύσεις την πρώτη σου ανάρτηση.', - 'skip_step' => 'Παράλειψη αυτού του βήματος', - 'continue' => 'Συνέχεια στο TryPost', - 'status' => [ - 'complete' => 'Ολοκληρώθηκε', - 'todo' => 'Εκκρεμεί', - 'skipped' => 'Παραλείφθηκε', + 'title' => 'Τι σας περιγράφει καλύτερα;', + 'description' => 'Επιλέξτε την πιο κοντινή επιλογή και θα προσαρμόσουμε την εμπειρία σας.', + 'continue' => 'Συνέχεια', + 'subscription_required_title' => 'Αναμονή για τον κάτοχο του λογαριασμού', + 'subscription_required_description' => 'Αυτός ο λογαριασμός δεν έχει ακόμη ενεργή συνδρομή. Ζητήστε από τον κάτοχο να ολοκληρώσει την πληρωμή — θα έχετε πλήρη πρόσβαση μόλις ενεργοποιηθεί.', + 'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.', + 'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.', + 'progress' => 'Πρόοδος ενσωμάτωσης', + 'go_to_step' => 'Μετάβαση στο βήμα :step', + 'step_current' => 'Βήμα :step (τρέχον)', + 'personas' => [ + 'creator' => 'Δημιουργός περιεχομένου', + 'freelancer' => 'Ελεύθερος επαγγελματίας', + 'developer' => 'Προγραμματιστής', + 'startup' => 'Startup', + 'agency' => 'Πρακτορείο', + 'small_business' => 'Μικρή επιχείρηση', + 'marketer' => 'Marketer', + 'online_store' => 'Ηλεκτρονικό κατάστημα', + 'other' => 'Άλλο', ], - 'mcp' => [ - 'title' => 'Σύνδεσε τον βοηθό AI σου', - 'description' => 'Πρόσθεσε το TryPost ως διακομιστή MCP ώστε ο βοηθός σου να δημιουργεί και να διαχειρίζεται social posts για εσένα.', - 'copied' => 'Το URL MCP αντιγράφηκε.', + 'goals_title' => 'Ποιος είναι ο στόχος σας;', + 'goals_description' => 'Επιλέξτε αυτή που σας ταιριάζει περισσότερο και θα ρυθμίσουμε το TryPost για εσάς.', + 'goals' => [ + 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', + 'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost', + 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor', + 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', + 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', + 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', + 'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις', + 'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών', + 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', + 'other' => 'Κάτι άλλο', ], - 'social' => [ - 'title' => 'Σύνδεσε έναν λογαριασμό social', - 'description' => 'Διάλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.', - 'connected_elsewhere' => 'Έχεις ήδη συνδέσει λογαριασμό σε άλλο workspace, οπότε αυτό το βήμα ολοκληρώθηκε.', + 'referral_source_title' => 'Πώς μας βρήκατε;', + 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Κατάλογος (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Φίλος ή συνάδελφος', + 'founder' => 'Ιδρυτής', + 'blog' => 'Blog / newsletter', + 'other' => 'Κάτι άλλο', ], - 'first_post' => [ - 'title' => 'Δημιούργησε την πρώτη σου ανάρτηση', - 'description' => 'Δοκίμασε αυτό το αρχικό prompt με τον συνδεδεμένο βοηθό σου ή δημιούργησε την ανάρτηση απευθείας στο TryPost.', - 'prompt_label' => 'Δείγμα prompt', - 'sample_prompt' => 'Δημιούργησε μια φιλική social ανάρτηση που παρουσιάζει το brand μου και προσαρμοσέ την για κάθε συνδεδεμένο δίκτυο.', - 'copy_prompt' => 'Αντιγραφή prompt', - 'copied' => 'Το δείγμα prompt αντιγράφηκε.', - 'create_button' => 'Δημιούργησε την πρώτη σου ανάρτηση', - 'or' => 'ή', + 'connect' => [ + 'title' => 'Ποιο δίκτυο χρησιμοποιείς περισσότερο;', + 'description' => 'Διάλεξε αυτό στο οποίο δημοσιεύεις πιο συχνά.', + 'follow_up' => 'Σύνδεσε τον λογαριασμό :network.', + 'latest_post' => 'Αυτή είναι η πιο πρόσφατη ανάρτησή σου.', + 'pitch_views' => '{0} Αυτή η ανάρτηση είχε :views προβολές στο :network.|{1} Αυτή η ανάρτηση είχε :views προβολή στο :network.|[2,*] Αυτή η ανάρτηση είχε :views προβολές στο :network.', + 'pitch_no_views' => 'Αυτή η ανάρτηση βγήκε μόνο στο :network.', + 'pitch_missed' => '{0}|{1} Η ίδια ανάρτηση στο :first μπορεί εύκολα να πάρει άλλες :each προβολές. Είναι :extra άνθρωποι που δεν την είδαν.|[2,*] Η ίδια ανάρτηση στο :first και στο :second μπορεί εύκολα να πάρει άλλες :each προβολές στο καθένα. Είναι :extra άνθρωποι που δεν την είδαν.', + 'pitch_sales' => 'Μπορεί να χάνεις χιλιάδες πωλήσεις.', + 'change_network' => 'Άλλαξε δίκτυο', + 'required' => 'Σύνδεσε τουλάχιστον έναν λογαριασμό κοινωνικής δικτύωσης για να συνεχίσεις.', ], - 'ready' => [ - 'title' => 'Είσαι έτοιμος να δημοσιεύσεις', - 'description' => 'Όλα είναι έτοιμα. Συνέχισε στο TryPost και ξεκίνα να σχεδιάζεις το περιεχόμενό σου.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/el/posts.php b/lang/el/posts.php index a4592be22..19a59c1ce 100644 --- a/lang/el/posts.php +++ b/lang/el/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Ακύρωση', ], - 'create' => [ - 'title' => 'Δημιουργία νέας δημοσίευσης', - 'description' => 'Επιλέξτε πώς θέλετε να ξεκινήσετε.', - 'scratch_title' => 'Ξεκινήστε από την αρχή', - 'scratch_description' => 'Ανοίξτε μια κενή δημοσίευση και γράψτε τα πάντα μόνοι σας.', - 'ai_title' => 'Δημιουργία με AI', - 'ai_description' => 'Περιγράψτε τι θέλετε και το AI δημιουργεί το περιεχόμενο για εσάς.', - 'ai_configure_description' => 'Επιλέξτε μια μορφή και περιγράψτε τη δημοσίευση που θέλετε να δημιουργήσετε.', - 'ai_pick_template_description' => 'Επιλέξτε ένα στυλ για τη δημοσίευσή σας που δημιουργείται από AI.', - 'coming_soon' => 'Έρχεται σύντομα', - - 'preview' => [ - 'image_title' => 'Τίτλος εικόνας', - 'image_body' => 'Σώμα εικόνας', - ], - - 'steps' => [ - 'template_picker_title' => 'Επιλέξτε ένα στυλ', - 'format_title' => 'Επιλέξτε μια μορφή', - 'format_description' => 'Επιλέξτε τον τύπο δημοσίευσης που θέλετε να δημιουργήσετε.', - 'account_title' => 'Επιλέξτε έναν λογαριασμό', - 'account_description' => 'Επιλέξτε τον λογαριασμό κοινωνικού δικτύου για δημοσίευση.', - 'media_title' => 'Επιλογές πολυμέσων', - 'media_carousel' => 'Πόσα slides;', - 'media_optional' => 'Να συμπεριληφθούν εικόνες;', - 'media_optional_label' => 'Πόσες εικόνες;', - 'media_none' => 'Καμία', - 'media_count_label' => 'Αριθμός εικόνων', - 'brand_colors_label' => 'Χρώματα επωνυμίας', - 'brand_colors_description' => 'Εφάρμοσε την παλέτα της επωνυμίας σου στις εικόνες που δημιουργούνται. Απενεργοποίησέ το για να επιλέξει το AI χρώματα που ταιριάζουν στο περιεχόμενο.', - 'prompt_title' => 'Περιγράψτε τη δημοσίευσή σας', - 'prompt_label' => 'Ποιο είναι το θέμα αυτής της δημοσίευσης;', - 'prompt_placeholder' => 'π.χ. Ανακοινώστε τη νέα μας λειτουργία carousel για το Instagram', - 'preview_error' => 'Κάτι πήγε στραβά. Παρακαλούμε δοκιμάστε ξανά.', - 'loading_page_title' => 'Δημιουργία της δημοσίευσής σας', - 'loading_eta' => 'Εκτιμώμενος χρόνος: περίπου :minutes.', - 'loading_eta_minute_one' => '1 λεπτό', - 'loading_eta_minute_other' => ':count λεπτά', - 'loading_leave_title' => 'Μπορείτε να συνεχίσετε να εργάζεστε.', - 'loading_leave_body' => 'Θα σας ειδοποιήσουμε όταν η δημοσίευση είναι έτοιμη.', - 'loading_leave_cta' => 'Μετάβαση στο ημερολόγιο', - 'loading_create_another_cta' => 'Δημιουργία άλλης δημοσίευσης', - 'loading_tip_credits' => 'Κάθε εικόνα AI χρησιμοποιεί περίπου 15 credits.', - 'loading_tip_edit' => 'Θα μπορείτε να επεξεργαστείτε τα πάντα μόλις η δημοσίευση είναι έτοιμη.', - 'loading_tip_draft' => 'Οι δημιουργημένες δημοσιεύσεις καταλήγουν στα πρόχειρά σας.', - 'loading_tip_brand' => 'Προσαρμόστε τις ρυθμίσεις της μάρκας σας για να επηρεάσετε μελλοντικές δημοσιεύσεις.', - 'loading_tip_carousel' => 'Τα carousels παρέχουν ένα slide ανά μεταφορτωμένη εικόνα.', - 'loading_tip_quality' => 'Η ποιότητα εικόνας ρυθμίζεται για ισορροπία ταχύτητας και κόστους.', - 'create' => 'Δημιουργία δημοσίευσης', - 'back' => 'Πίσω', - 'next' => 'Συνέχεια', - 'cancel' => 'Ακύρωση', - 'discard' => 'Απόρριψη', - 'retry' => 'Δοκιμάστε ξανά', - 'no_platforms' => 'Δεν υπάρχουν συνδεδεμένοι λογαριασμοί', - 'connect_first' => 'Συνδέστε τουλάχιστον έναν λογαριασμό κοινωνικού δικτύου για να χρησιμοποιήσετε τη δημιουργία με AI.', - 'no_account_for_template' => 'Συνδέστε έναν συμβατό λογαριασμό για να χρησιμοποιήσετε αυτό το πρότυπο.', - - 'format' => [ - 'instagram_feed' => 'Δημοσίευση feed Instagram', - 'instagram_carousel' => 'Carousel Instagram', - 'linkedin_post' => 'Δημοσίευση LinkedIn', - 'linkedin_page_post' => 'Δημοσίευση σελίδας LinkedIn', - 'x_post' => 'Δημοσίευση X', - 'bluesky_post' => 'Δημοσίευση Bluesky', - 'threads_post' => 'Δημοσίευση Threads', - 'mastodon_post' => 'Δημοσίευση Mastodon', - 'telegram_post' => 'Δημοσίευση Telegram', - 'discord_message' => 'Μήνυμα Discord', - 'facebook_post' => 'Δημοσίευση Facebook', - 'pinterest_pin' => 'Pin Pinterest', - 'instagram_story' => 'Story Instagram', - 'facebook_story' => 'Story Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Δημοσίευση feed Instagram', + 'instagram_carousel' => 'Carousel Instagram', + 'linkedin_post' => 'Δημοσίευση LinkedIn', + 'linkedin_page_post' => 'Δημοσίευση σελίδας LinkedIn', + 'x_post' => 'Δημοσίευση X', + 'bluesky_post' => 'Δημοσίευση Bluesky', + 'threads_post' => 'Δημοσίευση Threads', + 'mastodon_post' => 'Δημοσίευση Mastodon', + 'facebook_post' => 'Δημοσίευση Facebook', + 'pinterest_pin' => 'Pin Pinterest', + 'instagram_story' => 'Story Instagram', ], ]; diff --git a/lang/el/sidebar.php b/lang/el/sidebar.php index 821646742..71812cf9f 100644 --- a/lang/el/sidebar.php +++ b/lang/el/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Στατιστικά', 'automations' => 'Αυτοματισμοί', - 'onboarding' => 'Ξεκινώντας', - 'onboarding_hint' => 'Ολοκλήρωση ρύθμισης', + 'browse' => 'Μενού', + 'chat' => 'Συνομιλία', + 'chat_history' => 'Ιστορικό', + 'new_chat' => 'Νέα συνομιλία', + 'no_chats' => 'Δεν υπάρχουν συνομιλίες', + 'last_7_days' => 'Τελευταίες 7 ημέρες', + 'last_30_days' => 'Τελευταίες 30 ημέρες', + 'older' => 'Παλαιότερα', 'posts' => [ 'calendar' => 'Ημερολόγιο', 'all' => 'Όλες', diff --git a/lang/el/welcome.php b/lang/el/welcome.php deleted file mode 100644 index f4ad9557d..000000000 --- a/lang/el/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Τι σας περιγράφει καλύτερα;', - 'description' => 'Επιλέξτε την πιο κοντινή επιλογή και θα προσαρμόσουμε την εμπειρία σας.', - 'continue' => 'Συνέχεια', - 'subscription_required_title' => 'Αναμονή για τον κάτοχο του λογαριασμού', - 'subscription_required_description' => 'Αυτός ο λογαριασμός δεν έχει ακόμη ενεργή συνδρομή. Ζητήστε από τον κάτοχο να ολοκληρώσει την πληρωμή — θα έχετε πλήρη πρόσβαση μόλις ενεργοποιηθεί.', - 'subscription_required_owner' => 'Ο κάτοχος του λογαριασμού σας είναι ο/η :name.', - 'subscription_required_auto' => 'Αυτή η σελίδα ενημερώνεται αυτόματα — δεν χρειάζεται ανανέωση.', - 'progress' => 'Πρόοδος καλωσορίσματος', - 'go_to_step' => 'Μετάβαση στο βήμα :step', - 'step_current' => 'Βήμα :step (τρέχον)', - 'personas' => [ - 'creator' => 'Δημιουργός περιεχομένου', - 'freelancer' => 'Ελεύθερος επαγγελματίας', - 'developer' => 'Προγραμματιστής', - 'startup' => 'Startup', - 'agency' => 'Πρακτορείο', - 'small_business' => 'Μικρή επιχείρηση', - 'marketer' => 'Marketer', - 'online_store' => 'Ηλεκτρονικό κατάστημα', - 'other' => 'Άλλο', - ], - 'goals_title' => 'Ποιος είναι ο στόχος σας;', - 'goals_description' => 'Επιλέξτε ό,τι σας ταιριάζει και θα ρυθμίσουμε το TryPost για εσάς.', - 'goals' => [ - 'save_time' => 'Εξοικονόμηση χρόνου δημοσιεύοντας παντού ταυτόχρονα', - 'ai_content' => 'Δημιουργία δημοσιεύσεων με το AI του TryPost', - 'use_mcp' => 'Δημιουργία δημοσιεύσεων από Claude, ChatGPT ή Cursor', - 'plan_calendar' => 'Προγραμματισμός των δημοσιεύσεών μου σε ημερολόγιο', - 'stay_on_brand' => 'Διατήρηση κάθε δημοσίευσης εναρμονισμένης με τη μάρκα', - 'grow_audience' => 'Ανάπτυξη του κοινού και της αλληλεπίδρασής μου', - 'drive_sales' => 'Περισσότερη επισκεψιμότητα και πωλήσεις', - 'manage_clients' => 'Διαχείριση πολλών μαρκών ή πελατών', - 'just_exploring' => 'Απλώς εξερευνώ προς το παρόν', - 'other' => 'Κάτι άλλο', - ], - 'referral_source_title' => 'Πώς μας βρήκατε;', - 'referral_source_description' => 'Αυτό μας βοηθά να καταλάβουμε πώς οι άνθρωποι ανακαλύπτουν το TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Κατάλογος (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Φίλος ή συνάδελφος', - 'founder' => 'Ιδρυτής', - 'blog' => 'Blog / newsletter', - 'other' => 'Κάτι άλλο', - ], - 'connect' => [ - 'title' => 'Σύνδεσε έναν λογαριασμό κοινωνικής δικτύωσης', - 'description' => 'Επίλεξε τουλάχιστον ένα δίκτυο όπου το TryPost μπορεί να δημοσιεύει το περιεχόμενό σου.', - 'required' => 'Σύνδεσε τουλάχιστον έναν λογαριασμό κοινωνικής δικτύωσης για να συνεχίσεις.', - ], -]; diff --git a/lang/en/chat.php b/lang/en/chat.php new file mode 100644 index 000000000..55d072fbf --- /dev/null +++ b/lang/en/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'How can I help with your posts?', + 'description' => 'Ask about recent posts or how they performed — like talking to TryPost MCP, without Claude or ChatGPT.', + 'placeholder' => 'Ask about your posts or metrics', + 'send' => 'Send', + 'suggestions_label' => 'Try asking about', + 'suggestions' => [ + 'posts' => 'Recent posts', + 'metrics' => 'Post metrics', + ], + 'thinking' => 'Thinking…', + 'groups' => [ + 'today' => 'Today', + 'yesterday' => 'Yesterday', + 'last_7_days' => 'Last 7 days', + 'last_30_days' => 'Last 30 days', + 'older' => 'Older', + ], + 'tools' => [ + 'post_not_found' => 'Post not found.', + 'error' => 'Something went wrong. Please try again.', + 'publish_no_enabled_platforms' => 'This post has no enabled platforms. Enable at least one before publishing.', + 'delete_blocked' => "This post has already been published and can't be deleted from TryPost. If it's live on a platform that doesn't support automatic removal, like TikTok, remove it there manually.", + 'forbidden' => "You don't have permission to create or change posts in this workspace. Ask a workspace admin if you need it.", + ], + 'approvals' => [ + 'publish' => 'Publish this post now? It will go live immediately on every enabled platform, and some platforms — like TikTok — offer no way to unpublish afterward.', + 'delete_scheduled' => 'Deleting this post can\'t be undone. If it\'s scheduled to publish, this also cancels that. Delete anyway?', + ], + 'errors' => [ + 'turn_in_progress' => 'This conversation is still replying. Wait for it to finish before sending another message.', + 'request_failed' => 'Something went wrong sending your message. Please try again.', + 'stream_failed' => 'Something went wrong while replying. Try again.', + 'payment_required_cta' => 'Manage billing', + 'access_error' => "This conversation isn't available. It may have been deleted, or it belongs to someone else.", + ], + 'tool_names' => [ + 'list_posts' => 'List posts', + 'get_post' => 'Post details', + 'get_post_metrics' => 'Post metrics', + 'create_post' => 'Create post', + 'update_post' => 'Update post', + 'schedule_post' => 'Schedule post', + 'publish_post' => 'Publish post', + 'delete_post' => 'Delete post', + 'start_post_generation' => 'Post options', + 'generate_post' => 'Generate post', + ], + 'tool_card' => [ + 'running' => 'Running…', + 'denied' => 'You declined this action.', + 'unknown_tool' => "This assistant used a tool this interface doesn't have a card for yet.", + 'unreadable_result' => "This result couldn't be read.", + 'empty_list' => 'No posts found.', + 'open_in_editor' => 'Open in editor', + 'untitled' => 'No content yet.', + 'post_deleted' => 'This post was deleted.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Not published yet', + 'platform_not_supported' => "Metrics aren't available for this platform", + ], + 'empty' => 'No metrics to show yet.', + ], + 'approval' => [ + 'approve' => 'Approve', + 'reject' => 'Reject', + 'generic_reason' => 'This action needs your approval before it happens.', + ], + 'post_generation' => [ + 'unavailable' => "This workspace has no connected account yet, so there's no format to generate a post for. Connect an account first.", + 'styles_unavailable' => "No post styles are available right now, so there's nothing to generate this format with.", + 'format_question' => 'Which format should I generate?', + 'style_question' => 'Which style should it use?', + 'images_question' => 'How many images should it have?', + 'images_none' => 'None', + 'account_question' => 'Which account is this for?', + 'topic_line' => 'About: :topic', + 'posting_to' => 'Posting to :account', + 'brand_colors_label' => 'Brand colors', + 'brand_colors_description' => 'Apply your workspace brand palette to the generated images.', + 'change' => 'Change', + 'submit' => 'Generate post', + 'sent' => 'Choices sent.', + 'sentence' => 'Generate :format about :topic, :style style, :images, on :account.', + 'sentence_with_brand' => 'Generate :format about :topic, :style style, :images, on :account, :brand.', + 'sentence_images_none' => 'no images', + 'sentence_images_one' => '1 image', + 'sentence_images_other' => ':count images', + 'sentence_brand_on' => 'with brand colors', + 'sentence_brand_off' => 'without brand colors', + 'result_waiting' => 'Generating your post…', + 'result_elapsed_label' => 'Elapsed: :elapsed', + 'result_waiting_hint' => "This keeps running if you leave. The post shows up here when it's ready.", + 'result_detached_hint' => 'This keeps running in the background. Reopen this conversation to see the post.', + 'result_ready' => 'Your post is ready.', + 'result_failed' => "The post couldn't be generated. Ask again to retry.", + ], +]; diff --git a/lang/en/onboarding.php b/lang/en/onboarding.php index 694edc9ad..61063b9f7 100644 --- a/lang/en/onboarding.php +++ b/lang/en/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'What best describes you?', + 'description' => 'Choose the closest match and we\'ll tailor your experience.', + 'continue' => 'Continue', + 'subscription_required_title' => 'Waiting for the account owner', + 'subscription_required_description' => 'This account doesn\'t have an active subscription yet. Ask the account owner to finish checkout — you\'ll get full access as soon as it is active.', + 'subscription_required_owner' => 'Your account owner is :name.', + 'subscription_required_auto' => 'This page updates automatically — no need to refresh.', + 'progress' => 'Onboarding progress', + 'go_to_step' => 'Go to step :step', + 'step_current' => 'Step :step (current)', + 'personas' => [ + 'creator' => 'Content creator', + 'freelancer' => 'Freelancer', + 'developer' => 'Developer', + 'startup' => 'Startup', + 'agency' => 'Agency', + 'small_business' => 'Small business', + 'marketer' => 'Marketer', + 'online_store' => 'Online store', + 'other' => 'Other', ], - '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.', + 'goals_title' => 'What\'s your goal?', + 'goals_description' => 'Pick the closest match and we\'ll set TryPost up for you.', + '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', + 'plan_calendar' => 'Plan my posts on a calendar', + 'stay_on_brand' => 'Keep every post on brand', + 'grow_audience' => 'Grow my audience and engagement', + 'drive_sales' => 'Get more traffic and sales', + 'manage_clients' => 'Manage several brands or clients', + 'just_exploring' => 'Just exploring for now', + 'other' => 'Something else', ], - '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.', + 'referral_source_title' => 'How did you find us?', + 'referral_source_description' => 'This helps us understand how people discover TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Directory (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Friend or colleague', + 'founder' => 'Founder', + 'blog' => 'Blog / newsletter', + 'other' => 'Something else', ], - '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', + 'connect' => [ + 'title' => 'Which social do you use most?', + 'description' => 'Pick the one you post on the most.', + 'follow_up' => 'Connect your :network account.', + 'latest_post' => "Here's your latest post.", + 'pitch_views' => '{0} This post got :views views on :network.|{1} This post got :views view on :network.|[2,*] This post got :views views on :network.', + 'pitch_no_views' => 'This post only went out on :network.', + 'pitch_missed' => '{0}|{1} The same post on :first could easily pick up another :each views. That is :extra people who never saw it.|[2,*] The same post on :first and :second could easily pick up another :each views on each. That is :extra people who never saw it.', + 'pitch_sales' => 'You might be leaving thousands in sales on the table.', + 'change_network' => 'Change network', + 'required' => 'Connect at least one social account to continue.', ], - 'ready' => [ - 'title' => 'You are ready to publish', - 'description' => 'You are set. Continue to TryPost and start planning your content.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/en/posts.php b/lang/en/posts.php index ffdf6cecf..9e489e0f0 100644 --- a/lang/en/posts.php +++ b/lang/en/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Cancel', ], - 'create' => [ - 'title' => 'Create a new post', - 'description' => 'Choose how you want to start.', - 'scratch_title' => 'Start from scratch', - 'scratch_description' => 'Open a blank post and write everything yourself.', - 'ai_title' => 'Generate with AI', - 'ai_description' => 'Describe what you want and AI generates the content for you.', - 'ai_configure_description' => 'Pick a format and describe the post you want to create.', - 'ai_pick_template_description' => 'Choose a style for your AI-generated post.', - 'coming_soon' => 'Coming soon', - - 'preview' => [ - 'image_title' => 'Image title', - 'image_body' => 'Image body', - ], - - 'steps' => [ - 'template_picker_title' => 'Choose a style', - 'format_title' => 'Choose a format', - 'format_description' => 'Select the type of post you want to create.', - 'account_title' => 'Choose an account', - 'account_description' => 'Select the social account to publish to.', - 'media_title' => 'Media options', - 'media_carousel' => 'How many slides?', - 'media_optional' => 'Include images?', - 'media_optional_label' => 'How many images?', - 'media_none' => 'None', - 'media_count_label' => 'Number of images', - 'brand_colors_label' => 'Brand colors', - 'brand_colors_description' => 'Apply your workspace brand palette to the generated images. Turn it off to let the AI pick colors that fit the content.', - 'prompt_title' => 'Describe your post', - 'prompt_label' => 'What is this post about?', - 'prompt_placeholder' => 'e.g. Announce our new carousel feature for Instagram', - 'preview_error' => 'Something went wrong. Please try again.', - 'loading_page_title' => 'Generating your post', - 'loading_eta' => 'Estimated time: about :minutes.', - 'loading_eta_minute_one' => '1 minute', - 'loading_eta_minute_other' => ':count minutes', - 'loading_leave_title' => 'You can keep working.', - 'loading_leave_body' => 'We will notify you when the post is ready.', - 'loading_leave_cta' => 'Go to calendar', - 'loading_create_another_cta' => 'Create another post', - 'loading_tip_credits' => 'Each AI image uses about 15 credits.', - 'loading_tip_edit' => 'You will be able to edit everything once the post is ready.', - 'loading_tip_draft' => 'Generated posts land in your drafts.', - 'loading_tip_brand' => 'Tweak your brand settings to influence future posts.', - 'loading_tip_carousel' => 'Carousels deliver one slide per uploaded image.', - 'loading_tip_quality' => 'Image quality is set to balance speed and cost.', - 'create' => 'Create post', - 'back' => 'Back', - 'next' => 'Continue', - 'cancel' => 'Cancel', - 'discard' => 'Discard', - 'retry' => 'Try again', - 'no_platforms' => 'No connected accounts', - 'connect_first' => 'Connect at least one social account to use AI generation.', - 'no_account_for_template' => 'Connect a compatible account to use this template.', - - 'format' => [ - 'instagram_feed' => 'Instagram Feed Post', - 'instagram_carousel' => 'Instagram Carousel', - 'linkedin_post' => 'LinkedIn Post', - 'linkedin_page_post' => 'LinkedIn Page Post', - 'x_post' => 'X Post', - 'bluesky_post' => 'Bluesky Post', - 'threads_post' => 'Threads Post', - 'mastodon_post' => 'Mastodon Post', - 'telegram_post' => 'Telegram Post', - 'discord_message' => 'Discord Message', - 'facebook_post' => 'Facebook Post', - 'pinterest_pin' => 'Pinterest Pin', - 'instagram_story' => 'Instagram Story', - 'facebook_story' => 'Facebook Story', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram Feed Post', + 'instagram_carousel' => 'Instagram Carousel', + 'linkedin_post' => 'LinkedIn Post', + 'linkedin_page_post' => 'LinkedIn Page Post', + 'x_post' => 'X Post', + 'bluesky_post' => 'Bluesky Post', + 'threads_post' => 'Threads Post', + 'mastodon_post' => 'Mastodon Post', + 'facebook_post' => 'Facebook Post', + 'pinterest_pin' => 'Pinterest Pin', + 'instagram_story' => 'Instagram Story', ], ]; diff --git a/lang/en/sidebar.php b/lang/en/sidebar.php index 2b4f5c4a1..4a92dcde1 100644 --- a/lang/en/sidebar.php +++ b/lang/en/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analytics', 'automations' => 'Automations', - 'onboarding' => 'Getting started', - 'onboarding_hint' => 'Finish setup', + 'browse' => 'Browse', + 'chat' => 'Chat', + 'chat_history' => 'Chat history', + 'new_chat' => 'New chat', + 'no_chats' => 'No chats yet', + 'last_7_days' => 'Last 7 days', + 'last_30_days' => 'Last 30 days', + 'older' => 'Older', 'posts' => [ 'calendar' => 'Calendar', 'all' => 'All', diff --git a/lang/en/welcome.php b/lang/en/welcome.php deleted file mode 100644 index 1664e5aba..000000000 --- a/lang/en/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'What best describes you?', - 'description' => 'Choose the closest match and we\'ll tailor your experience.', - 'continue' => 'Continue', - 'subscription_required_title' => 'Waiting for the account owner', - 'subscription_required_description' => 'This account doesn\'t have an active subscription yet. Ask the account owner to finish checkout — you\'ll get full access as soon as it is active.', - '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)', - 'personas' => [ - 'creator' => 'Content creator', - 'freelancer' => 'Freelancer', - 'developer' => 'Developer', - 'startup' => 'Startup', - 'agency' => 'Agency', - 'small_business' => 'Small business', - 'marketer' => 'Marketer', - 'online_store' => 'Online store', - 'other' => 'Other', - ], - 'goals_title' => 'What\'s your goal?', - 'goals_description' => 'Pick everything that fits and we\'ll set TryPost up for you.', - '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', - 'plan_calendar' => 'Plan my posts on a calendar', - 'stay_on_brand' => 'Keep every post on brand', - 'grow_audience' => 'Grow my audience and engagement', - 'drive_sales' => 'Get more traffic and sales', - 'manage_clients' => 'Manage several brands or clients', - 'just_exploring' => 'Just exploring for now', - 'other' => 'Something else', - ], - 'referral_source_title' => 'How did you find us?', - 'referral_source_description' => 'This helps us understand how people discover TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Directory (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Friend or colleague', - 'founder' => 'Founder', - 'blog' => 'Blog / newsletter', - 'other' => 'Something else', - ], - 'connect' => [ - 'title' => 'Connect a social account', - 'description' => 'Choose at least one network where TryPost can publish your content.', - 'required' => 'Connect at least one social account to continue.', - ], -]; diff --git a/lang/es/chat.php b/lang/es/chat.php new file mode 100644 index 000000000..c0770b1c3 --- /dev/null +++ b/lang/es/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => '¿Cómo puedo ayudarte con tus posts?', + 'description' => 'Pregunta por los posts recientes o su rendimiento — como hablar con el MCP de TryPost, sin Claude ni ChatGPT.', + 'placeholder' => 'Pregunta por posts o métricas', + 'send' => 'Enviar', + 'suggestions_label' => 'Prueba a preguntar por', + 'suggestions' => [ + 'posts' => 'Posts recientes', + 'metrics' => 'Métricas de posts', + ], + 'thinking' => 'Pensando…', + 'groups' => [ + 'today' => 'Hoy', + 'yesterday' => 'Ayer', + 'last_7_days' => 'Últimos 7 días', + 'last_30_days' => 'Últimos 30 días', + 'older' => 'Anteriores', + ], + 'tools' => [ + 'post_not_found' => 'Post no encontrado.', + 'error' => 'Algo salió mal. Inténtalo de nuevo.', + 'publish_no_enabled_platforms' => 'Este post no tiene plataformas activadas. Activa al menos una antes de publicar.', + 'delete_blocked' => 'Este post ya se publicó y no se puede eliminar de TryPost. Si está activo en una plataforma que no admite la eliminación automática, como TikTok, tendrás que eliminarlo allí manualmente.', + 'forbidden' => 'No tienes permiso para crear ni modificar posts en este espacio de trabajo. Pídeselo a un administrador del espacio de trabajo si lo necesitas.', + ], + 'approvals' => [ + 'publish' => '¿Publicar este post ahora? Se publicará de inmediato en todas las plataformas activadas, y algunas — como TikTok — no ofrecen forma de despublicarlo después.', + 'delete_scheduled' => 'Eliminar este post no se puede deshacer. Si está programado para publicarse, esto también lo cancela. ¿Eliminar de todas formas?', + ], + 'errors' => [ + 'turn_in_progress' => 'Esta conversación todavía está respondiendo. Espera a que termine antes de enviar otro mensaje.', + 'request_failed' => 'Algo salió mal al enviar tu mensaje. Inténtalo de nuevo.', + 'stream_failed' => 'Algo salió mal al responder. Inténtalo de nuevo.', + 'payment_required_cta' => 'Gestionar facturación', + 'access_error' => 'Esta conversación no está disponible. Puede que se haya eliminado o que pertenezca a otra persona.', + ], + 'tool_names' => [ + 'list_posts' => 'Listar posts', + 'get_post' => 'Detalles del post', + 'get_post_metrics' => 'Métricas del post', + 'create_post' => 'Crear post', + 'update_post' => 'Actualizar post', + 'schedule_post' => 'Programar post', + 'publish_post' => 'Publicar post', + 'delete_post' => 'Eliminar post', + 'start_post_generation' => 'Opciones de publicación', + 'generate_post' => 'Generar publicación', + ], + 'tool_card' => [ + 'running' => 'Ejecutando…', + 'denied' => 'Rechazaste esta acción.', + 'unknown_tool' => 'El asistente usó una herramienta para la que esta interfaz aún no tiene una tarjeta.', + 'unreadable_result' => 'Este resultado no se pudo leer.', + 'empty_list' => 'No se encontraron posts.', + 'open_in_editor' => 'Abrir en el editor', + 'untitled' => 'Sin contenido todavía.', + 'post_deleted' => 'Este post se eliminó.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Aún no publicado', + 'platform_not_supported' => 'Las métricas no están disponibles para esta plataforma', + ], + 'empty' => 'Todavía no hay métricas que mostrar.', + ], + 'approval' => [ + 'approve' => 'Aprobar', + 'reject' => 'Rechazar', + 'generic_reason' => 'Esta acción necesita tu aprobación antes de ejecutarse.', + ], + 'post_generation' => [ + 'unavailable' => 'Este espacio de trabajo aún no tiene ninguna cuenta conectada, así que no hay ningún formato para generar una publicación. Conecta una cuenta primero.', + 'styles_unavailable' => 'Ahora mismo no hay ningún estilo de publicación disponible, así que no hay con qué generar este formato.', + 'format_question' => '¿Qué formato quieres que genere?', + 'style_question' => '¿Qué estilo debe usar?', + 'images_question' => '¿Cuántas imágenes quieres?', + 'images_none' => 'Ninguna', + 'account_question' => '¿Para qué cuenta es?', + 'topic_line' => 'Sobre: :topic', + 'posting_to' => 'Publicando en :account', + 'brand_colors_label' => 'Colores de marca', + 'brand_colors_description' => 'Aplica la paleta de tu marca a las imágenes generadas.', + 'change' => 'Cambiar', + 'submit' => 'Generar publicación', + 'sent' => 'Opciones enviadas.', + 'sentence' => 'Generar :format sobre :topic, estilo :style, :images, en :account.', + 'sentence_with_brand' => 'Generar :format sobre :topic, estilo :style, :images, en :account, :brand.', + 'sentence_images_none' => 'sin imágenes', + 'sentence_images_one' => '1 imagen', + 'sentence_images_other' => ':count imágenes', + 'sentence_brand_on' => 'con los colores de marca', + 'sentence_brand_off' => 'sin los colores de marca', + 'result_waiting' => 'Generando tu publicación…', + 'result_elapsed_label' => 'Tiempo transcurrido: :elapsed', + 'result_waiting_hint' => 'Esto sigue en marcha aunque salgas. La publicación aparecerá aquí en cuanto esté lista.', + 'result_detached_hint' => 'Esto sigue en marcha en segundo plano. Vuelve a abrir esta conversación para ver la publicación.', + 'result_ready' => 'Tu publicación está lista.', + 'result_failed' => 'No se pudo generar la publicación. Pídela de nuevo para reintentarlo.', + ], +]; diff --git a/lang/es/onboarding.php b/lang/es/onboarding.php index 844ba7180..fc7e4e977 100644 --- a/lang/es/onboarding.php +++ b/lang/es/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => '¿Qué te describe mejor?', + 'description' => 'Elige la opción más cercana y personalizaremos tu experiencia.', + 'continue' => 'Continuar', + 'subscription_required_title' => 'Esperando al propietario de la cuenta', + 'subscription_required_description' => 'Esta cuenta aún no tiene una suscripción activa. Pide al propietario que complete el checkout: tendrás acceso total en cuanto esté activa.', + '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 onboarding', + 'go_to_step' => 'Ir al paso :step', + 'step_current' => 'Paso :step (actual)', + 'personas' => [ + 'creator' => 'Creador de contenido', + 'freelancer' => 'Freelancer', + 'developer' => 'Desarrollador', + 'startup' => 'Startup', + 'agency' => 'Agencia', + 'small_business' => 'Pequeña empresa', + 'marketer' => 'Profesional de marketing', + 'online_store' => 'Tienda online', + 'other' => 'Otro', ], - '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.', + 'goals_title' => '¿Cuál es tu objetivo?', + 'goals_description' => 'Elige la que más encaje y adaptamos TryPost a ti.', + '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', + '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', + 'drive_sales' => 'Conseguir más tráfico y ventas', + 'manage_clients' => 'Gestionar varias marcas o clientes', + 'just_exploring' => 'Solo estoy explorando por ahora', + 'other' => 'Otra cosa', ], - '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.', + 'referral_source_title' => '¿Cómo nos encontraste?', + 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Directorio (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Amigo o colega', + 'founder' => 'Fundador', + 'blog' => 'Blog / newsletter', + 'other' => 'Otra cosa', ], - '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', + 'connect' => [ + 'title' => '¿En qué red publicas más?', + 'description' => 'Elige la que más usas.', + 'follow_up' => 'Conecta tu cuenta de :network.', + 'latest_post' => 'Esta es tu última publicación.', + 'pitch_views' => '{0} Esta publicación tuvo :views views en :network.|{1} Esta publicación tuvo :views view en :network.|[2,*] Esta publicación tuvo :views views en :network.', + 'pitch_no_views' => 'Esta publicación solo salió en :network.', + 'pitch_missed' => '{0}|{1} La misma publicación en :first podría sumar otras :each views. Son :extra personas que no la vieron.|[2,*] La misma publicación en :first y :second podría sumar otras :each views en cada una. Son :extra personas que no la vieron.', + 'pitch_sales' => 'Puedes estar dejando miles en ventas sobre la mesa.', + 'change_network' => 'Cambiar de red', + 'required' => 'Conecta al menos una red social para continuar.', ], - 'ready' => [ - 'title' => 'Listo para publicar', - 'description' => 'Todo listo. Continúa a TryPost y empieza a planificar tu contenido.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/es/posts.php b/lang/es/posts.php index 566e78ecc..f7f9f8125 100644 --- a/lang/es/posts.php +++ b/lang/es/posts.php @@ -591,81 +591,18 @@ 'cancel' => 'Cancelar', ], - 'create' => [ - 'title' => 'Crear nuevo post', - 'description' => 'Elige cómo quieres empezar.', - 'scratch_title' => 'Empezar desde cero', - 'scratch_description' => 'Abre un post en blanco para escribirlo todo.', - 'ai_title' => 'Generar con IA', - 'ai_description' => 'Describe lo que quieres y la IA genera el contenido por ti.', - 'ai_configure_description' => 'Elige un formato y describe el post que quieres crear.', - 'ai_pick_template_description' => 'Elige un estilo para tu post generado por IA.', - - 'preview' => [ - 'image_title' => 'Título de la imagen', - 'image_body' => 'Texto de la imagen', - ], - - 'coming_soon' => 'Próximamente', - - 'steps' => [ - 'template_picker_title' => 'Elige un estilo', - 'format_title' => 'Elige un formato', - 'format_description' => 'Selecciona el tipo de post que quieres crear.', - 'account_title' => 'Elige una cuenta', - 'account_description' => 'Selecciona la cuenta social donde publicar.', - 'media_title' => 'Opciones de medios', - 'media_carousel' => '¿Cuántas diapositivas?', - 'media_optional' => '¿Incluir imágenes?', - 'media_optional_label' => '¿Cuántas imágenes?', - 'media_none' => 'Ninguna', - 'media_count_label' => 'Número de imágenes', - 'brand_colors_label' => 'Colores de marca', - 'brand_colors_description' => 'Aplica la paleta de tu marca a las imágenes generadas. Desactívalo para que la IA elija los colores que mejor encajen con el contenido.', - 'prompt_title' => 'Describe tu post', - 'prompt_label' => '¿De qué trata este post?', - 'prompt_placeholder' => 'Ej. Anuncia nuestra nueva función de carrusel para Instagram', - 'preview_error' => 'Algo salió mal. Por favor, inténtalo de nuevo.', - 'loading_page_title' => 'Generando tu publicación', - 'loading_eta' => 'Tiempo estimado: cerca de :minutes.', - 'loading_eta_minute_one' => '1 minuto', - 'loading_eta_minute_other' => ':count minutos', - 'loading_leave_title' => 'Puedes seguir trabajando.', - 'loading_leave_body' => 'Te avisamos cuando la publicación esté lista.', - 'loading_leave_cta' => 'Ir al calendario', - 'loading_create_another_cta' => 'Crear otra publicación', - 'loading_tip_credits' => 'Cada imagen IA usa unos 15 créditos.', - 'loading_tip_edit' => 'Podrás editar todo cuando la publicación esté lista.', - 'loading_tip_draft' => 'Las publicaciones generadas van directo a tus borradores.', - 'loading_tip_brand' => 'Ajusta tu marca para influir en las próximas publicaciones.', - 'loading_tip_carousel' => 'Los carruseles generan una diapositiva por imagen solicitada.', - 'loading_tip_quality' => 'La calidad balancea velocidad y costo.', - 'create' => 'Crear post', - 'back' => 'Atrás', - 'next' => 'Continuar', - 'cancel' => 'Cancelar', - 'discard' => 'Descartar', - 'retry' => 'Intentar de nuevo', - 'no_platforms' => 'Sin cuentas conectadas', - 'connect_first' => 'Conecta al menos una cuenta social para usar la generación con IA.', - 'no_account_for_template' => 'Conecta una cuenta compatible para usar esta plantilla.', - - 'format' => [ - 'instagram_feed' => 'Post de Feed de Instagram', - 'instagram_carousel' => 'Carrusel de Instagram', - 'linkedin_post' => 'Post de LinkedIn', - 'linkedin_page_post' => 'Post de Página de LinkedIn', - 'x_post' => 'Post en X', - 'bluesky_post' => 'Post en Bluesky', - 'threads_post' => 'Post en Threads', - 'mastodon_post' => 'Post en Mastodon', - 'telegram_post' => 'Post en Telegram', - 'discord_message' => 'Mensaje de Discord', - 'facebook_post' => 'Post en Facebook', - 'pinterest_pin' => 'Pin de Pinterest', - 'instagram_story' => 'Story de Instagram', - 'facebook_story' => 'Story de Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Post de Feed de Instagram', + 'instagram_carousel' => 'Carrusel de Instagram', + 'linkedin_post' => 'Post de LinkedIn', + 'linkedin_page_post' => 'Post de Página de LinkedIn', + 'x_post' => 'Post en X', + 'bluesky_post' => 'Post en Bluesky', + 'threads_post' => 'Post en Threads', + 'mastodon_post' => 'Post en Mastodon', + 'facebook_post' => 'Post en Facebook', + 'pinterest_pin' => 'Pin de Pinterest', + 'instagram_story' => 'Story de Instagram', ], ]; diff --git a/lang/es/sidebar.php b/lang/es/sidebar.php index aeb9b33df..03834e7bf 100644 --- a/lang/es/sidebar.php +++ b/lang/es/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analytics', 'automations' => 'Automatizaciones', - 'onboarding' => 'Primeros pasos', - 'onboarding_hint' => 'Termina la configuración', + 'browse' => 'Menú', + 'chat' => 'Chat', + 'chat_history' => 'Historial', + 'new_chat' => 'Nuevo chat', + 'no_chats' => 'Aún no hay chats', + 'last_7_days' => 'Últimos 7 días', + 'last_30_days' => 'Últimos 30 días', + 'older' => 'Más antiguos', 'posts' => [ 'calendar' => 'Calendario', 'all' => 'Todos', diff --git a/lang/es/welcome.php b/lang/es/welcome.php deleted file mode 100644 index f63779152..000000000 --- a/lang/es/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - '¿Qué te describe mejor?', - 'description' => 'Elige la opción más cercana y personalizaremos tu experiencia.', - 'continue' => 'Continuar', - 'subscription_required_title' => 'Esperando al propietario de la cuenta', - 'subscription_required_description' => 'Esta cuenta aún no tiene una suscripción activa. Pide al propietario que complete el checkout: tendrás acceso total en cuanto esté activa.', - '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)', - 'personas' => [ - 'creator' => 'Creador de contenido', - 'freelancer' => 'Freelancer', - 'developer' => 'Desarrollador', - 'startup' => 'Startup', - 'agency' => 'Agencia', - 'small_business' => 'Pequeña empresa', - 'marketer' => 'Profesional de marketing', - 'online_store' => 'Tienda online', - 'other' => 'Otro', - ], - 'goals_title' => '¿Cuál es tu objetivo?', - 'goals_description' => 'Marca todo lo que encaje y adaptamos TryPost a ti.', - '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', - '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', - 'drive_sales' => 'Conseguir más tráfico y ventas', - 'manage_clients' => 'Gestionar varias marcas o clientes', - 'just_exploring' => 'Solo estoy explorando por ahora', - 'other' => 'Otra cosa', - ], - 'referral_source_title' => '¿Cómo nos encontraste?', - 'referral_source_description' => 'Esto nos ayuda a entender cómo la gente descubre TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Directorio (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Amigo o colega', - 'founder' => 'Fundador', - 'blog' => 'Blog / newsletter', - 'other' => 'Otra cosa', - ], - 'connect' => [ - 'title' => 'Conecta una red social', - 'description' => 'Elige al menos una red donde TryPost pueda publicar tu contenido.', - 'required' => 'Conecta al menos una red social para continuar.', - ], -]; diff --git a/lang/fr/chat.php b/lang/fr/chat.php new file mode 100644 index 000000000..f7ccc67fb --- /dev/null +++ b/lang/fr/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'Comment puis-je aider avec vos posts ?', + 'description' => 'Demandez vos posts récents ou leurs performances — comme parler au MCP TryPost, sans Claude ni ChatGPT.', + 'placeholder' => 'Posez une question sur vos posts ou métriques', + 'send' => 'Envoyer', + 'suggestions_label' => 'Essayez de demander', + 'suggestions' => [ + 'posts' => 'Posts récents', + 'metrics' => 'Métriques des posts', + ], + 'thinking' => 'Réflexion en cours…', + 'groups' => [ + 'today' => 'Aujourd\'hui', + 'yesterday' => 'Hier', + 'last_7_days' => '7 derniers jours', + 'last_30_days' => '30 derniers jours', + 'older' => 'Plus anciens', + ], + 'tools' => [ + 'post_not_found' => 'Post introuvable.', + 'error' => 'Une erreur s’est produite. Réessayez.', + 'publish_no_enabled_platforms' => 'Ce post n’a aucune plateforme activée. Activez-en au moins une avant de publier.', + 'delete_blocked' => 'Ce post a déjà été publié et ne peut pas être supprimé de TryPost. S’il est en ligne sur une plateforme qui ne prend pas en charge la suppression automatique, comme TikTok, vous devrez le supprimer vous-même là-bas.', + 'forbidden' => 'Vous n’avez pas l’autorisation de créer ou de modifier des posts dans cet espace de travail. Demandez-la à un administrateur de l’espace de travail si vous en avez besoin.', + ], + 'approvals' => [ + 'publish' => 'Publier ce post maintenant ? Il sera immédiatement en ligne sur chaque plateforme activée, et certaines — comme TikTok — n’offrent aucun moyen de le dépublier ensuite.', + 'delete_scheduled' => 'La suppression de ce post est irréversible. S’il est programmé pour être publié, cela l’annule aussi. Le supprimer quand même ?', + ], + 'errors' => [ + 'turn_in_progress' => 'Cette conversation est encore en train de répondre. Attendez la fin avant d\'envoyer un autre message.', + 'request_failed' => 'Une erreur s’est produite lors de l’envoi de votre message. Réessayez.', + 'stream_failed' => 'Une erreur s’est produite pendant la réponse. Réessayez.', + 'payment_required_cta' => 'Gérer la facturation', + 'access_error' => 'Cette conversation n’est pas disponible. Elle a peut-être été supprimée ou appartient à quelqu’un d’autre.', + ], + 'tool_names' => [ + 'list_posts' => 'Lister les posts', + 'get_post' => 'Détails du post', + 'get_post_metrics' => 'Métriques du post', + 'create_post' => 'Créer un post', + 'update_post' => 'Mettre à jour le post', + 'schedule_post' => 'Planifier le post', + 'publish_post' => 'Publier le post', + 'delete_post' => 'Supprimer le post', + 'start_post_generation' => 'Options de publication', + 'generate_post' => 'Générer une publication', + ], + 'tool_card' => [ + 'running' => 'En cours…', + 'denied' => 'Vous avez refusé cette action.', + 'unknown_tool' => 'L’assistant a utilisé un outil pour lequel cette interface n’a pas encore de carte.', + 'unreadable_result' => 'Ce résultat n’a pas pu être lu.', + 'empty_list' => 'Aucun post trouvé.', + 'open_in_editor' => 'Ouvrir dans l’éditeur', + 'untitled' => 'Pas encore de contenu.', + 'post_deleted' => 'Ce post a été supprimé.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Pas encore publié', + 'platform_not_supported' => 'Les métriques ne sont pas disponibles pour cette plateforme', + ], + 'empty' => 'Aucune métrique à afficher pour l’instant.', + ], + 'approval' => [ + 'approve' => 'Approuver', + 'reject' => 'Refuser', + 'generic_reason' => 'Cette action nécessite votre approbation avant de s’exécuter.', + ], + 'post_generation' => [ + 'unavailable' => "Cet espace de travail n'a encore aucun compte connecté, il n'y a donc aucun format pour générer une publication. Connectez d'abord un compte.", + 'styles_unavailable' => "Aucun style de publication n'est disponible pour le moment, il n'y a donc rien pour générer ce format.", + 'format_question' => 'Quel format dois-je générer ?', + 'style_question' => 'Quel style dois-je utiliser ?', + 'images_question' => 'Combien d’images voulez-vous ?', + 'images_none' => 'Aucune', + 'account_question' => 'Pour quel compte est-ce ?', + 'topic_line' => 'Sujet : :topic', + 'posting_to' => 'Publication sur :account', + 'brand_colors_label' => 'Couleurs de marque', + 'brand_colors_description' => 'Applique la palette de votre marque aux images générées.', + 'change' => 'Modifier', + 'submit' => 'Générer la publication', + 'sent' => 'Choix envoyés.', + 'sentence' => 'Générer :format à propos de :topic, style :style, :images, sur :account.', + 'sentence_with_brand' => 'Générer :format à propos de :topic, style :style, :images, sur :account, :brand.', + 'sentence_images_none' => 'sans images', + 'sentence_images_one' => '1 image', + 'sentence_images_other' => ':count images', + 'sentence_brand_on' => 'avec les couleurs de marque', + 'sentence_brand_off' => 'sans les couleurs de marque', + 'result_waiting' => 'Génération de votre publication…', + 'result_elapsed_label' => 'Temps écoulé : :elapsed', + 'result_waiting_hint' => "Le traitement continue même si vous quittez la page. La publication apparaîtra ici dès qu'elle sera prête.", + 'result_detached_hint' => 'Le traitement continue en arrière-plan. Rouvrez cette conversation pour voir la publication.', + 'result_ready' => 'Votre publication est prête.', + 'result_failed' => "La publication n'a pas pu être générée. Redemandez-la pour réessayer.", + ], +]; diff --git a/lang/fr/onboarding.php b/lang/fr/onboarding.php index 1455dcdc9..f1abe52dc 100644 --- a/lang/fr/onboarding.php +++ b/lang/fr/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'Qu\'est-ce qui vous décrit le mieux ?', + 'description' => 'Choisissez l\'option la plus proche et nous personnaliserons votre expérience.', + 'continue' => 'Continuer', + 'subscription_required_title' => 'En attente du propriétaire du compte', + 'subscription_required_description' => 'Ce compte n\'a pas encore d\'abonnement actif. Demandez au propriétaire de finaliser le paiement — vous aurez un accès complet dès qu\'il sera actif.', + '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 de l’onboarding', + 'go_to_step' => 'Aller à l’étape :step', + 'step_current' => 'Étape :step (actuelle)', + 'personas' => [ + 'creator' => 'Créateur de contenu', + 'freelancer' => 'Freelance', + 'developer' => 'Développeur', + 'startup' => 'Startup', + 'agency' => 'Agence', + 'small_business' => 'Petite entreprise', + 'marketer' => 'Marketeur', + 'online_store' => 'Boutique en ligne', + 'other' => 'Autre', ], - '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.', + 'goals_title' => 'Quel est votre objectif ?', + 'goals_description' => 'Choisissez celle qui vous correspond le mieux et nous configurerons TryPost pour vous.', + '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', + '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', + 'drive_sales' => 'Obtenir plus de trafic et de ventes', + 'manage_clients' => 'Gérer plusieurs marques ou clients', + 'just_exploring' => 'Je découvre pour l\'instant', + 'other' => 'Autre chose', ], - '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.', + 'referral_source_title' => 'Comment nous avez-vous connus ?', + 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Annuaire (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Ami ou collègue', + 'founder' => 'Fondateur', + 'blog' => 'Blog / newsletter', + 'other' => 'Autre chose', ], - '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', + 'connect' => [ + 'title' => 'Quel réseau utilisez-vous le plus ?', + 'description' => 'Choisissez celui où vous publiez le plus.', + 'follow_up' => 'Connectez votre compte :network.', + 'latest_post' => 'Voici votre dernière publication.', + 'pitch_views' => '{0} Ce post a eu :views vues sur :network.|{1} Ce post a eu :views vue sur :network.|[2,*] Ce post a eu :views vues sur :network.', + 'pitch_no_views' => 'Ce post n’est sorti que sur :network.', + 'pitch_missed' => '{0}|{1} Le même post sur :first pourrait facilement faire :each vues de plus. Ce sont :extra personnes qui ne l’ont jamais vu.|[2,*] Le même post sur :first et :second pourrait facilement faire :each vues de plus sur chacun. Ce sont :extra personnes qui ne l’ont jamais vu.', + 'pitch_sales' => 'Vous laissez peut-être des milliers de ventes sur la table.', + 'change_network' => 'Changer de réseau', + 'required' => 'Connectez au moins un réseau social pour continuer.', ], - 'ready' => [ - 'title' => 'Vous êtes prêt à publier', - 'description' => 'Tout est bon. Continuez vers TryPost et commencez à planifier votre contenu.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/fr/posts.php b/lang/fr/posts.php index b246551f2..14d8b7bd9 100644 --- a/lang/fr/posts.php +++ b/lang/fr/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Annuler', ], - 'create' => [ - 'title' => 'Créer une nouvelle publication', - 'description' => 'Choisissez comment vous souhaitez commencer.', - 'scratch_title' => 'Partir de zéro', - 'scratch_description' => 'Ouvrez une publication vierge et rédigez tout vous-même.', - 'ai_title' => 'Générer avec l\'IA', - 'ai_description' => 'Décrivez ce que vous voulez et l\'IA génère le contenu pour vous.', - 'ai_configure_description' => 'Choisissez un format et décrivez la publication que vous souhaitez créer.', - 'ai_pick_template_description' => 'Choisissez un style pour votre publication générée par l\'IA.', - 'coming_soon' => 'Bientôt disponible', - - 'preview' => [ - 'image_title' => 'Titre de l\'image', - 'image_body' => 'Corps de l\'image', - ], - - 'steps' => [ - 'template_picker_title' => 'Choisissez un style', - 'format_title' => 'Choisissez un format', - 'format_description' => 'Sélectionnez le type de publication que vous souhaitez créer.', - 'account_title' => 'Choisissez un compte', - 'account_description' => 'Sélectionnez le compte social sur lequel publier.', - 'media_title' => 'Options de médias', - 'media_carousel' => 'Combien de diapositives ?', - 'media_optional' => 'Inclure des images ?', - 'media_optional_label' => 'Combien d\'images ?', - 'media_none' => 'Aucune', - 'media_count_label' => 'Nombre d\'images', - 'brand_colors_label' => 'Couleurs de la marque', - 'brand_colors_description' => 'Appliquez la palette de votre marque aux images générées. Désactivez pour laisser l’IA choisir des couleurs adaptées au contenu.', - 'prompt_title' => 'Décrivez votre publication', - 'prompt_label' => 'De quoi parle cette publication ?', - 'prompt_placeholder' => 'par ex. Annoncer notre nouvelle fonctionnalité de carrousel pour Instagram', - 'preview_error' => 'Une erreur s\'est produite. Veuillez réessayer.', - 'loading_page_title' => 'Génération de votre publication', - 'loading_eta' => 'Temps estimé : environ :minutes.', - 'loading_eta_minute_one' => '1 minute', - 'loading_eta_minute_other' => ':count minutes', - 'loading_leave_title' => 'Vous pouvez continuer à travailler.', - 'loading_leave_body' => 'Nous vous préviendrons lorsque la publication sera prête.', - 'loading_leave_cta' => 'Aller au calendrier', - 'loading_create_another_cta' => 'Créer une autre publication', - 'loading_tip_credits' => 'Chaque image IA utilise environ 15 crédits.', - 'loading_tip_edit' => 'Vous pourrez tout modifier une fois la publication prête.', - 'loading_tip_draft' => 'Les publications générées arrivent dans vos brouillons.', - 'loading_tip_brand' => 'Ajustez les paramètres de votre marque pour influencer les futures publications.', - 'loading_tip_carousel' => 'Les carrousels affichent une diapositive par image importée.', - 'loading_tip_quality' => 'La qualité des images est réglée pour équilibrer vitesse et coût.', - 'create' => 'Créer la publication', - 'back' => 'Retour', - 'next' => 'Continuer', - 'cancel' => 'Annuler', - 'discard' => 'Abandonner', - 'retry' => 'Réessayer', - 'no_platforms' => 'Aucun compte connecté', - 'connect_first' => 'Connectez au moins un compte social pour utiliser la génération par IA.', - 'no_account_for_template' => 'Connectez un compte compatible pour utiliser ce modèle.', - - 'format' => [ - 'instagram_feed' => 'Publication Instagram (fil)', - 'instagram_carousel' => 'Carrousel Instagram', - 'linkedin_post' => 'Publication LinkedIn', - 'linkedin_page_post' => 'Publication de page LinkedIn', - 'x_post' => 'Publication X', - 'bluesky_post' => 'Publication Bluesky', - 'threads_post' => 'Publication Threads', - 'mastodon_post' => 'Publication Mastodon', - 'telegram_post' => 'Publication Telegram', - 'discord_message' => 'Message Discord', - 'facebook_post' => 'Publication Facebook', - 'pinterest_pin' => 'Épingle Pinterest', - 'instagram_story' => 'Story Instagram', - 'facebook_story' => 'Story Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Publication Instagram (fil)', + 'instagram_carousel' => 'Carrousel Instagram', + 'linkedin_post' => 'Publication LinkedIn', + 'linkedin_page_post' => 'Publication de page LinkedIn', + 'x_post' => 'Publication X', + 'bluesky_post' => 'Publication Bluesky', + 'threads_post' => 'Publication Threads', + 'mastodon_post' => 'Publication Mastodon', + 'facebook_post' => 'Publication Facebook', + 'pinterest_pin' => 'Épingle Pinterest', + 'instagram_story' => 'Story Instagram', ], ]; diff --git a/lang/fr/sidebar.php b/lang/fr/sidebar.php index 14208543d..ecf7f75fe 100644 --- a/lang/fr/sidebar.php +++ b/lang/fr/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Statistiques', 'automations' => 'Automatisations', - 'onboarding' => 'Premiers pas', - 'onboarding_hint' => 'Terminer la configuration', + 'browse' => 'Menu', + 'chat' => 'Chat', + 'chat_history' => 'Historique', + 'new_chat' => 'Nouveau chat', + 'no_chats' => 'Aucun chat pour le moment', + 'last_7_days' => '7 derniers jours', + 'last_30_days' => '30 derniers jours', + 'older' => 'Plus anciens', 'posts' => [ 'calendar' => 'Calendrier', 'all' => 'Toutes', diff --git a/lang/fr/welcome.php b/lang/fr/welcome.php deleted file mode 100644 index bbc8fddd5..000000000 --- a/lang/fr/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Qu\'est-ce qui vous décrit le mieux ?', - 'description' => 'Choisissez l\'option la plus proche et nous personnaliserons votre expérience.', - 'continue' => 'Continuer', - 'subscription_required_title' => 'En attente du propriétaire du compte', - 'subscription_required_description' => 'Ce compte n\'a pas encore d\'abonnement actif. Demandez au propriétaire de finaliser le paiement — vous aurez un accès complet dès qu\'il sera actif.', - '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)', - 'personas' => [ - 'creator' => 'Créateur de contenu', - 'freelancer' => 'Freelance', - 'developer' => 'Développeur', - 'startup' => 'Startup', - 'agency' => 'Agence', - 'small_business' => 'Petite entreprise', - 'marketer' => 'Marketeur', - 'online_store' => 'Boutique en ligne', - 'other' => 'Autre', - ], - 'goals_title' => 'Quel est votre objectif ?', - 'goals_description' => 'Choisissez tout ce qui vous correspond et nous configurerons TryPost pour vous.', - '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', - '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', - 'drive_sales' => 'Obtenir plus de trafic et de ventes', - 'manage_clients' => 'Gérer plusieurs marques ou clients', - 'just_exploring' => 'Je découvre pour l\'instant', - 'other' => 'Autre chose', - ], - 'referral_source_title' => 'Comment nous avez-vous connus ?', - 'referral_source_description' => 'Cela nous aide à comprendre comment les gens découvrent TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Annuaire (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Ami ou collègue', - 'founder' => 'Fondateur', - 'blog' => 'Blog / newsletter', - 'other' => 'Autre chose', - ], - 'connect' => [ - 'title' => 'Connectez un réseau social', - 'description' => 'Choisissez au moins un réseau sur lequel TryPost peut publier votre contenu.', - 'required' => 'Connectez au moins un réseau social pour continuer.', - ], -]; diff --git a/lang/it/chat.php b/lang/it/chat.php new file mode 100644 index 000000000..6b037beaf --- /dev/null +++ b/lang/it/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'Come posso aiutarti con i tuoi post?', + 'description' => 'Chiedi dei post recenti o delle loro performance — come parlare con l’MCP di TryPost, senza Claude o ChatGPT.', + 'placeholder' => 'Chiedi dei post o delle metriche', + 'send' => 'Invia', + 'suggestions_label' => 'Prova a chiedere', + 'suggestions' => [ + 'posts' => 'Post recenti', + 'metrics' => 'Metriche dei post', + ], + 'thinking' => 'Sto pensando…', + 'groups' => [ + 'today' => 'Oggi', + 'yesterday' => 'Ieri', + 'last_7_days' => 'Ultimi 7 giorni', + 'last_30_days' => 'Ultimi 30 giorni', + 'older' => 'Meno recenti', + ], + 'tools' => [ + 'post_not_found' => 'Post non trovato.', + 'error' => 'Qualcosa è andato storto. Riprova.', + 'publish_no_enabled_platforms' => 'Questo post non ha piattaforme abilitate. Abilitane almeno una prima di pubblicare.', + 'delete_blocked' => 'Questo post è già stato pubblicato e non può essere eliminato da TryPost. Se è attivo su una piattaforma che non supporta la rimozione automatica, come TikTok, dovrai eliminarlo lì manualmente.', + 'forbidden' => 'Non hai il permesso di creare o modificare post in questo spazio di lavoro. Chiedilo a un amministratore dello spazio di lavoro se ti serve.', + ], + 'approvals' => [ + 'publish' => 'Pubblicare subito questo post? Sarà immediatamente attivo su ogni piattaforma abilitata, e alcune — come TikTok — non offrono modo di rimuoverlo in seguito.', + 'delete_scheduled' => 'L’eliminazione di questo post non può essere annullata. Se è programmato per la pubblicazione, questo lo annulla anche. Eliminare comunque?', + ], + 'errors' => [ + 'turn_in_progress' => 'Questa conversazione sta ancora rispondendo. Attendi che finisca prima di inviare un altro messaggio.', + 'request_failed' => 'Si è verificato un errore nell’invio del messaggio. Riprova.', + 'stream_failed' => 'Si è verificato un errore durante la risposta. Riprova.', + 'payment_required_cta' => 'Gestisci fatturazione', + 'access_error' => 'Questa conversazione non è disponibile. Potrebbe essere stata eliminata o appartenere a qualcun altro.', + ], + 'tool_names' => [ + 'list_posts' => 'Elenca post', + 'get_post' => 'Dettagli del post', + 'get_post_metrics' => 'Metriche del post', + 'create_post' => 'Crea post', + 'update_post' => 'Aggiorna post', + 'schedule_post' => 'Pianifica post', + 'publish_post' => 'Pubblica post', + 'delete_post' => 'Elimina post', + 'start_post_generation' => 'Opzioni del post', + 'generate_post' => 'Genera post', + ], + 'tool_card' => [ + 'running' => 'In corso…', + 'denied' => 'Hai rifiutato questa azione.', + 'unknown_tool' => 'L’assistente ha usato uno strumento per cui questa interfaccia non ha ancora una scheda.', + 'unreadable_result' => 'Impossibile leggere questo risultato.', + 'empty_list' => 'Nessun post trovato.', + 'open_in_editor' => 'Apri nell’editor', + 'untitled' => 'Nessun contenuto ancora.', + 'post_deleted' => 'Questo post è stato eliminato.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Non ancora pubblicato', + 'platform_not_supported' => 'Le metriche non sono disponibili per questa piattaforma', + ], + 'empty' => 'Nessuna metrica da mostrare ancora.', + ], + 'approval' => [ + 'approve' => 'Approva', + 'reject' => 'Rifiuta', + 'generic_reason' => 'Questa azione richiede la tua approvazione prima di essere eseguita.', + ], + 'post_generation' => [ + 'unavailable' => "Questo workspace non ha ancora nessun account collegato, quindi non c'è alcun formato per cui generare un post. Collega prima un account.", + 'styles_unavailable' => "Al momento non è disponibile nessuno stile di post, quindi non c'è nulla con cui generare questo formato.", + 'format_question' => 'Quale formato devo generare?', + 'style_question' => 'Quale stile devo usare?', + 'images_question' => 'Quante immagini vuoi?', + 'images_none' => 'Nessuna', + 'account_question' => 'Per quale account è?', + 'topic_line' => 'Argomento: :topic', + 'posting_to' => 'Pubblicazione su :account', + 'brand_colors_label' => 'Colori del brand', + 'brand_colors_description' => 'Applica la palette del tuo brand alle immagini generate.', + 'change' => 'Modifica', + 'submit' => 'Genera post', + 'sent' => 'Scelte inviate.', + 'sentence' => 'Genera :format riguardo a :topic, stile :style, :images, su :account.', + 'sentence_with_brand' => 'Genera :format riguardo a :topic, stile :style, :images, su :account, :brand.', + 'sentence_images_none' => 'senza immagini', + 'sentence_images_one' => '1 immagine', + 'sentence_images_other' => ':count immagini', + 'sentence_brand_on' => 'con i colori del brand', + 'sentence_brand_off' => 'senza i colori del brand', + 'result_waiting' => 'Sto generando il tuo post…', + 'result_elapsed_label' => 'Tempo trascorso: :elapsed', + 'result_waiting_hint' => 'Continua a girare anche se esci dalla pagina. Il post compare qui appena è pronto.', + 'result_detached_hint' => 'Continua a girare in background. Riapri questa conversazione per vedere il post.', + 'result_ready' => 'Il tuo post è pronto.', + 'result_failed' => 'Non è stato possibile generare il post. Chiedilo di nuovo per riprovare.', + ], +]; diff --git a/lang/it/onboarding.php b/lang/it/onboarding.php index ac667df84..57e80295b 100644 --- a/lang/it/onboarding.php +++ b/lang/it/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'Cosa ti descrive meglio?', + 'description' => 'Scegli l\'opzione più vicina e personalizzeremo la tua esperienza.', + 'continue' => 'Continua', + 'subscription_required_title' => 'In attesa del proprietario dell\'account', + 'subscription_required_description' => 'Questo account non ha ancora un abbonamento attivo. Chiedi al proprietario di completare il checkout: avrai accesso completo non appena sarà attivo.', + 'subscription_required_owner' => 'Il proprietario del tuo account è :name.', + 'subscription_required_auto' => 'Questa pagina si aggiorna automaticamente: non serve ricaricarla.', + 'progress' => 'Progresso onboarding', + 'go_to_step' => 'Vai al passaggio :step', + 'step_current' => 'Passaggio :step (attuale)', + 'personas' => [ + 'creator' => 'Creatore di contenuti', + 'freelancer' => 'Freelance', + 'developer' => 'Sviluppatore', + 'startup' => 'Startup', + 'agency' => 'Agenzia', + 'small_business' => 'Piccola impresa', + 'marketer' => 'Marketer', + 'online_store' => 'Negozio online', + 'other' => 'Altro', ], - '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.', + 'goals_title' => 'Qual è il tuo obiettivo?', + 'goals_description' => 'Scegli quella che fa più al caso tuo e configureremo TryPost per te.', + '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', + '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', + 'drive_sales' => 'Ottenere più traffico e vendite', + 'manage_clients' => 'Gestire più brand o clienti', + 'just_exploring' => 'Sto solo dando un\'occhiata', + 'other' => 'Qualcos\'altro', ], - '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.', + 'referral_source_title' => 'Come ci hai trovato?', + 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Directory (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Amico o collega', + 'founder' => 'Fondatore', + 'blog' => 'Blog / newsletter', + 'other' => 'Qualcos\'altro', ], - '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', + 'connect' => [ + 'title' => 'Quale social usi di più?', + 'description' => 'Scegli quello su cui pubblichi di più.', + 'follow_up' => 'Collega il tuo account :network.', + 'latest_post' => 'Ecco il tuo ultimo post.', + 'pitch_views' => '{0} Questo post ha avuto :views view su :network.|{1} Questo post ha avuto :views view su :network.|[2,*] Questo post ha avuto :views view su :network.', + 'pitch_no_views' => 'Questo post è uscito solo su :network.', + 'pitch_missed' => '{0}|{1} Lo stesso post su :first potrebbe facilmente prendere altre :each view. Sono :extra persone che non l’hanno visto.|[2,*] Lo stesso post su :first e :second potrebbe facilmente prendere altre :each view su ciascuno. Sono :extra persone che non l’hanno visto.', + 'pitch_sales' => 'Potresti stare lasciando migliaia di vendite sul tavolo.', + 'change_network' => 'Cambia rete', + 'required' => 'Collega almeno un account social per continuare.', ], - 'ready' => [ - 'title' => 'Sei pronto a pubblicare', - 'description' => 'Tutto a posto. Continua su TryPost e inizia a pianificare i tuoi contenuti.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/it/posts.php b/lang/it/posts.php index 8bb0645e9..be4960dac 100644 --- a/lang/it/posts.php +++ b/lang/it/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Annulla', ], - 'create' => [ - 'title' => 'Crea un nuovo post', - 'description' => 'Scegli come vuoi iniziare.', - 'scratch_title' => 'Inizia da zero', - 'scratch_description' => 'Apri un post vuoto e scrivi tutto da solo.', - 'ai_title' => 'Genera con l\'IA', - 'ai_description' => 'Descrivi ciò che vuoi e l\'IA genera il contenuto per te.', - 'ai_configure_description' => 'Scegli un formato e descrivi il post che vuoi creare.', - 'ai_pick_template_description' => 'Scegli uno stile per il tuo post generato dall\'IA.', - 'coming_soon' => 'Prossimamente', - - 'preview' => [ - 'image_title' => 'Titolo immagine', - 'image_body' => 'Corpo immagine', - ], - - 'steps' => [ - 'template_picker_title' => 'Scegli uno stile', - 'format_title' => 'Scegli un formato', - 'format_description' => 'Seleziona il tipo di post che vuoi creare.', - 'account_title' => 'Scegli un account', - 'account_description' => 'Seleziona l\'account social su cui pubblicare.', - 'media_title' => 'Opzioni media', - 'media_carousel' => 'Quante slide?', - 'media_optional' => 'Includere immagini?', - 'media_optional_label' => 'Quante immagini?', - 'media_none' => 'Nessuna', - 'media_count_label' => 'Numero di immagini', - 'brand_colors_label' => 'Colori del brand', - 'brand_colors_description' => 'Applica la palette del tuo brand alle immagini generate. Disattivalo per lasciare che l’IA scelga i colori più adatti al contenuto.', - 'prompt_title' => 'Descrivi il tuo post', - 'prompt_label' => 'Di cosa parla questo post?', - 'prompt_placeholder' => 'es. Annuncia la nostra nuova funzione carosello per Instagram', - 'preview_error' => 'Qualcosa è andato storto. Riprova.', - 'loading_page_title' => 'Generazione del tuo post', - 'loading_eta' => 'Tempo stimato: circa :minutes.', - 'loading_eta_minute_one' => '1 minuto', - 'loading_eta_minute_other' => ':count minuti', - 'loading_leave_title' => 'Puoi continuare a lavorare.', - 'loading_leave_body' => 'Ti avviseremo quando il post sarà pronto.', - 'loading_leave_cta' => 'Vai al calendario', - 'loading_create_another_cta' => 'Crea un altro post', - 'loading_tip_credits' => 'Ogni immagine IA usa circa 15 crediti.', - 'loading_tip_edit' => 'Potrai modificare tutto una volta che il post sarà pronto.', - 'loading_tip_draft' => 'I post generati finiscono nelle tue bozze.', - 'loading_tip_brand' => 'Modifica le impostazioni del tuo brand per influenzare i post futuri.', - 'loading_tip_carousel' => 'I caroselli producono una slide per ogni immagine caricata.', - 'loading_tip_quality' => 'La qualità dell\'immagine è impostata per bilanciare velocità e costo.', - 'create' => 'Crea post', - 'back' => 'Indietro', - 'next' => 'Continua', - 'cancel' => 'Annulla', - 'discard' => 'Scarta', - 'retry' => 'Riprova', - 'no_platforms' => 'Nessun account collegato', - 'connect_first' => 'Collega almeno un account social per usare la generazione IA.', - 'no_account_for_template' => 'Collega un account compatibile per usare questo modello.', - - 'format' => [ - 'instagram_feed' => 'Post nel feed Instagram', - 'instagram_carousel' => 'Carosello Instagram', - 'linkedin_post' => 'Post LinkedIn', - 'linkedin_page_post' => 'Post pagina LinkedIn', - 'x_post' => 'Post X', - 'bluesky_post' => 'Post Bluesky', - 'threads_post' => 'Post Threads', - 'mastodon_post' => 'Post Mastodon', - 'telegram_post' => 'Post Telegram', - 'discord_message' => 'Messaggio Discord', - 'facebook_post' => 'Post Facebook', - 'pinterest_pin' => 'Pin Pinterest', - 'instagram_story' => 'Storia Instagram', - 'facebook_story' => 'Storia Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Post nel feed Instagram', + 'instagram_carousel' => 'Carosello Instagram', + 'linkedin_post' => 'Post LinkedIn', + 'linkedin_page_post' => 'Post pagina LinkedIn', + 'x_post' => 'Post X', + 'bluesky_post' => 'Post Bluesky', + 'threads_post' => 'Post Threads', + 'mastodon_post' => 'Post Mastodon', + 'facebook_post' => 'Post Facebook', + 'pinterest_pin' => 'Pin Pinterest', + 'instagram_story' => 'Storia Instagram', ], ]; diff --git a/lang/it/sidebar.php b/lang/it/sidebar.php index 61a15fc64..ece97162d 100644 --- a/lang/it/sidebar.php +++ b/lang/it/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Statistiche', 'automations' => 'Automazioni', - 'onboarding' => 'Primi passi', - 'onboarding_hint' => 'Completa la configurazione', + 'browse' => 'Menu', + 'chat' => 'Chat', + 'chat_history' => 'Cronologia', + 'new_chat' => 'Nuova chat', + 'no_chats' => 'Nessuna chat ancora', + 'last_7_days' => 'Ultimi 7 giorni', + 'last_30_days' => 'Ultimi 30 giorni', + 'older' => 'Più vecchi', 'posts' => [ 'calendar' => 'Calendario', 'all' => 'Tutti', diff --git a/lang/it/welcome.php b/lang/it/welcome.php deleted file mode 100644 index 8f34df080..000000000 --- a/lang/it/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Cosa ti descrive meglio?', - 'description' => 'Scegli l\'opzione più vicina e personalizzeremo la tua esperienza.', - 'continue' => 'Continua', - 'subscription_required_title' => 'In attesa del proprietario dell\'account', - 'subscription_required_description' => 'Questo account non ha ancora un abbonamento attivo. Chiedi al proprietario di completare il checkout: avrai accesso completo non appena sarà attivo.', - '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)', - 'personas' => [ - 'creator' => 'Creatore di contenuti', - 'freelancer' => 'Freelance', - 'developer' => 'Sviluppatore', - 'startup' => 'Startup', - 'agency' => 'Agenzia', - 'small_business' => 'Piccola impresa', - 'marketer' => 'Marketer', - 'online_store' => 'Negozio online', - 'other' => 'Altro', - ], - 'goals_title' => 'Qual è il tuo obiettivo?', - 'goals_description' => 'Scegli tutto ciò che fa per te e configureremo TryPost per te.', - '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', - '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', - 'drive_sales' => 'Ottenere più traffico e vendite', - 'manage_clients' => 'Gestire più brand o clienti', - 'just_exploring' => 'Sto solo dando un\'occhiata', - 'other' => 'Qualcos\'altro', - ], - 'referral_source_title' => 'Come ci hai trovato?', - 'referral_source_description' => 'Questo ci aiuta a capire come le persone scoprono TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Directory (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Amico o collega', - 'founder' => 'Fondatore', - 'blog' => 'Blog / newsletter', - 'other' => 'Qualcos\'altro', - ], - 'connect' => [ - 'title' => 'Collega un account social', - 'description' => 'Scegli almeno una rete su cui TryPost può pubblicare i tuoi contenuti.', - 'required' => 'Collega almeno un account social per continuare.', - ], -]; diff --git a/lang/ja/chat.php b/lang/ja/chat.php new file mode 100644 index 000000000..36495b420 --- /dev/null +++ b/lang/ja/chat.php @@ -0,0 +1,105 @@ + 'チャット', + 'headline' => '投稿について、何をお手伝いしましょうか?', + 'description' => '最近の投稿や成果を聞いてください。Claude や ChatGPT なしで TryPost MCP に話しかけるイメージです。', + 'placeholder' => '投稿や指標について聞く', + 'send' => '送信', + 'suggestions_label' => '例えば', + 'suggestions' => [ + 'posts' => '最近の投稿', + 'metrics' => '投稿の指標', + ], + 'thinking' => '考え中…', + 'groups' => [ + 'today' => '今日', + 'yesterday' => '昨日', + 'last_7_days' => '過去 7 日間', + 'last_30_days' => '過去 30 日間', + 'older' => 'それ以前', + ], + 'tools' => [ + 'post_not_found' => '投稿が見つかりません。', + 'error' => '問題が発生しました。もう一度お試しください。', + 'publish_no_enabled_platforms' => 'この投稿には有効なプラットフォームがありません。公開する前に少なくとも1つ有効にしてください。', + 'delete_blocked' => 'この投稿はすでに公開されているため、TryPostから削除できません。TikTokのように自動削除に対応していないプラットフォームで公開中の場合は、そちらで手動で削除してください。', + 'forbidden' => 'このワークスペースで投稿を作成・変更する権限がありません。必要な場合はワークスペースの管理者に依頼してください。', + ], + 'approvals' => [ + 'publish' => 'この投稿を今すぐ公開しますか?有効なすべてのプラットフォームで即座に公開されます。TikTokなど一部のプラットフォームは、公開後に取り消す方法がありません。', + 'delete_scheduled' => 'この投稿の削除は取り消せません。公開予定がある場合、その予定もキャンセルされます。それでも削除しますか?', + ], + 'errors' => [ + 'turn_in_progress' => 'この会話はまだ応答中です。完了してから次のメッセージを送信してください。', + 'request_failed' => 'メッセージの送信中に問題が発生しました。もう一度お試しください。', + 'stream_failed' => '応答中に問題が発生しました。もう一度お試しください。', + 'payment_required_cta' => '請求情報を管理', + 'access_error' => 'この会話は利用できません。削除されたか、別のユーザーのものである可能性があります。', + ], + 'tool_names' => [ + 'list_posts' => '投稿一覧を表示', + 'get_post' => '投稿の詳細', + 'get_post_metrics' => '投稿の指標', + 'create_post' => '投稿を作成', + 'update_post' => '投稿を更新', + 'schedule_post' => '投稿を予約', + 'publish_post' => '投稿を公開', + 'delete_post' => '投稿を削除', + 'start_post_generation' => '投稿オプション', + 'generate_post' => '投稿を生成', + ], + 'tool_card' => [ + 'running' => '実行中…', + 'denied' => 'この操作を拒否しました。', + 'unknown_tool' => 'アシスタントはこの画面にまだカードが用意されていないツールを使用しました。', + 'unreadable_result' => 'この結果は読み取れませんでした。', + 'empty_list' => '投稿が見つかりません。', + 'open_in_editor' => 'エディタで開く', + 'untitled' => 'まだ内容がありません。', + 'post_deleted' => 'この投稿は削除されました。', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'まだ公開されていません', + 'platform_not_supported' => 'このプラットフォームでは指標を利用できません', + ], + 'empty' => 'まだ表示できる指標がありません。', + ], + 'approval' => [ + 'approve' => '承認', + 'reject' => '拒否', + 'generic_reason' => 'この操作の実行には承認が必要です。', + ], + 'post_generation' => [ + 'unavailable' => 'このワークスペースにはまだ接続済みのアカウントがないため、投稿を生成できるフォーマットがありません。まずアカウントを接続してください。', + 'styles_unavailable' => '現在利用できる投稿スタイルがないため、このフォーマットを生成する手段がありません。', + 'format_question' => 'どの形式で作成しますか?', + 'style_question' => 'どのスタイルにしますか?', + 'images_question' => '画像は何枚にしますか?', + 'images_none' => 'なし', + 'account_question' => 'どのアカウント向けですか?', + 'topic_line' => 'テーマ: :topic', + 'posting_to' => ':account に投稿します', + 'brand_colors_label' => 'ブランドカラー', + 'brand_colors_description' => '生成される画像にワークスペースのブランドカラーを適用します。', + 'change' => '変更', + 'submit' => '投稿を生成', + 'sent' => '選択内容を送信しました。', + 'sentence' => ':account 向けに :topic についての :format を :style スタイル、:images で生成してください。', + 'sentence_with_brand' => ':account 向けに :topic についての :format を :style スタイル、:images、:brand で生成してください。', + 'sentence_images_none' => '画像なし', + 'sentence_images_one' => '画像1枚', + 'sentence_images_other' => '画像:count枚', + 'sentence_brand_on' => 'ブランドカラーあり', + 'sentence_brand_off' => 'ブランドカラーなし', + 'result_waiting' => '投稿を生成しています…', + 'result_elapsed_label' => '経過時間: :elapsed', + 'result_waiting_hint' => 'ページを離れても処理は続きます。準備ができると、ここに投稿が表示されます。', + 'result_detached_hint' => 'バックグラウンドで処理が続いています。この会話を開き直すと投稿を確認できます。', + 'result_ready' => '投稿の準備ができました。', + 'result_failed' => '投稿を生成できませんでした。もう一度依頼して再試行してください。', + ], +]; diff --git a/lang/ja/onboarding.php b/lang/ja/onboarding.php index 4ed5065e0..97bcf331b 100644 --- a/lang/ja/onboarding.php +++ b/lang/ja/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => 'はじめに', - 'welcome' => 'TryPostへようこそ、:nameさん', - 'description' => '以下のステップでTryPostの使い方を確認し、最初の投稿を公開しましょう。', - 'skip_step' => 'このステップをスキップ', - 'continue' => 'TryPostへ進む', - 'status' => [ - 'complete' => '完了', - 'todo' => '未完了', - 'skipped' => 'スキップ済み', + 'title' => 'あなたに一番近いのは?', + 'description' => '近いものを選ぶと、体験を最適化します。', + 'continue' => '続ける', + 'subscription_required_title' => 'アカウントのオーナーを待っています', + 'subscription_required_description' => 'このアカウントにはまだ有効なサブスクリプションがありません。オーナーにチェックアウトの完了を依頼してください — 有効になり次第、フルアクセスできます。', + 'subscription_required_owner' => 'アカウントのオーナーは :name です。', + 'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。', + 'progress' => 'オンボーディングの進捗', + 'go_to_step' => 'ステップ :step へ', + 'step_current' => 'ステップ :step(現在)', + 'personas' => [ + 'creator' => 'コンテンツクリエイター', + 'freelancer' => 'フリーランス', + 'developer' => '開発者', + 'startup' => 'スタートアップ', + 'agency' => '代理店', + 'small_business' => '中小企業', + 'marketer' => 'マーケター', + 'online_store' => 'オンラインストア', + 'other' => 'その他', ], - 'mcp' => [ - 'title' => 'AIアシスタントを接続', - 'description' => 'TryPostをMCPサーバーとして追加すると、アシスタントがSNS投稿の作成・管理を行えます。', - 'copied' => 'MCP URLをコピーしました。', + 'goals_title' => '目標は何ですか?', + 'goals_description' => 'いちばん近いものを選んでください。TryPost をあなた向けに設定します。', + 'goals' => [ + 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', + 'ai_content' => 'TryPost AI で投稿を生成する', + 'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する', + 'plan_calendar' => 'カレンダーで投稿を計画する', + 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', + 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', + 'drive_sales' => 'トラフィックと売上を増やす', + 'manage_clients' => '複数のブランドやクライアントを管理する', + 'just_exploring' => '今はまだ様子を見ている', + 'other' => 'その他', ], - 'social' => [ - 'title' => 'SNSアカウントを接続', - 'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選びます。', - 'connected_elsewhere' => '別のワークスペースでアカウントを接続済みなので、このステップは完了しています。', + 'referral_source_title' => 'どこで私たちを知りましたか?', + 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'ディレクトリ(G2、Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => '友人または同僚', + 'founder' => '創業者', + 'blog' => 'Blog / newsletter', + 'other' => 'その他', ], - 'first_post' => [ - 'title' => '最初の投稿を作成', - 'description' => '接続したアシスタントでこのスタータープロンプトを試すか、TryPostで直接投稿を作成します。', - 'prompt_label' => 'サンプルプロンプト', - 'sample_prompt' => 'ブランドを紹介する親しみやすいSNS投稿を作成し、接続済みの各ネットワーク向けに最適化してください。', - 'copy_prompt' => 'プロンプトをコピー', - 'copied' => 'サンプルプロンプトをコピーしました。', - 'create_button' => '最初の投稿を作成', - 'or' => 'または', + 'connect' => [ + 'title' => 'いちばんよく使うSNSは?', + 'description' => 'いちばん投稿しているものを選んでください。', + 'follow_up' => ':network アカウントを接続してください。', + 'latest_post' => '最新の投稿はこちらです。', + 'pitch_views' => '{0} この投稿は:networkで:views回表示されました。|{1} この投稿は:networkで:views回表示されました。|[2,*] この投稿は:networkで:views回表示されました。', + 'pitch_no_views' => 'この投稿は:networkにしか出ていません。', + 'pitch_missed' => '{0}|{1} 同じ投稿を:firstにも出せば、あと:each回の表示は十分あり得ます。見ていない人が:extra人いるということです。|[2,*] 同じ投稿を:firstと:secondにも出せば、それぞれあと:each回の表示は十分あり得ます。見ていない人が:extra人いるということです。', + 'pitch_sales' => '何千もの売上を逃しているかもしれません。', + 'change_network' => 'ネットワークを変更', + 'required' => '続けるには、少なくとも1つのSNSアカウントを接続してください。', ], - 'ready' => [ - 'title' => '公開の準備ができました', - 'description' => '設定完了です。TryPostへ進み、コンテンツの計画を始めましょう。', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/ja/posts.php b/lang/ja/posts.php index f0092fb3f..655620a4f 100644 --- a/lang/ja/posts.php +++ b/lang/ja/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'キャンセル', ], - 'create' => [ - 'title' => '新しい投稿を作成', - 'description' => 'どのように始めるかを選んでください。', - 'scratch_title' => '最初から作成', - 'scratch_description' => '空の投稿を開いて、すべて自分で書きます。', - 'ai_title' => 'AI で生成', - 'ai_description' => '作りたい内容を説明すると、AI がコンテンツを生成します。', - 'ai_configure_description' => '形式を選んで、作成したい投稿を説明してください。', - 'ai_pick_template_description' => 'AI が生成する投稿のスタイルを選んでください。', - 'coming_soon' => '近日公開', - - 'preview' => [ - 'image_title' => '画像のタイトル', - 'image_body' => '画像の本文', - ], - - 'steps' => [ - 'template_picker_title' => 'スタイルを選択', - 'format_title' => '形式を選択', - 'format_description' => '作成したい投稿の種類を選択してください。', - 'account_title' => 'アカウントを選択', - 'account_description' => '公開先のソーシャルアカウントを選択してください。', - 'media_title' => 'メディアオプション', - 'media_carousel' => 'スライドは何枚にしますか?', - 'media_optional' => '画像を含めますか?', - 'media_optional_label' => '画像は何枚にしますか?', - 'media_none' => 'なし', - 'media_count_label' => '画像の枚数', - 'brand_colors_label' => 'ブランドカラー', - 'brand_colors_description' => '生成される画像にワークスペースのブランドカラーを適用します。オフにすると、AI がコンテンツに合った色を選びます。', - 'prompt_title' => '投稿を説明', - 'prompt_label' => 'この投稿は何についてですか?', - 'prompt_placeholder' => '例: Instagram 向けの新しいカルーセル機能を発表', - 'preview_error' => '問題が発生しました。もう一度お試しください。', - 'loading_page_title' => '投稿を生成中', - 'loading_eta' => '推定時間: 約 :minutes。', - 'loading_eta_minute_one' => '1 分', - 'loading_eta_minute_other' => ':count 分', - 'loading_leave_title' => '作業を続けても大丈夫です。', - 'loading_leave_body' => '投稿の準備ができたらお知らせします。', - 'loading_leave_cta' => 'カレンダーへ移動', - 'loading_create_another_cta' => '別の投稿を作成', - 'loading_tip_credits' => 'AI 画像 1 枚につき約 15 クレジットを使用します。', - 'loading_tip_edit' => '投稿の準備ができたら、すべてを編集できます。', - 'loading_tip_draft' => '生成された投稿は下書きに保存されます。', - 'loading_tip_brand' => 'ブランド設定を調整すると、今後の投稿に反映されます。', - 'loading_tip_carousel' => 'カルーセルはアップロードした画像 1 枚につき 1 スライドを作成します。', - 'loading_tip_quality' => '画像の品質は、速度とコストのバランスを取るよう設定されています。', - 'create' => '投稿を作成', - 'back' => '戻る', - 'next' => '続ける', - 'cancel' => 'キャンセル', - 'discard' => '破棄', - 'retry' => 'もう一度試す', - 'no_platforms' => '接続済みのアカウントがありません', - 'connect_first' => 'AI 生成を使用するには、少なくとも 1 つのソーシャルアカウントを接続してください。', - 'no_account_for_template' => 'このテンプレートを使用するには、対応するアカウントを接続してください。', - - 'format' => [ - 'instagram_feed' => 'Instagram フィード投稿', - 'instagram_carousel' => 'Instagram カルーセル', - 'linkedin_post' => 'LinkedIn 投稿', - 'linkedin_page_post' => 'LinkedIn ページ投稿', - 'x_post' => 'X 投稿', - 'bluesky_post' => 'Bluesky 投稿', - 'threads_post' => 'Threads 投稿', - 'mastodon_post' => 'Mastodon 投稿', - 'telegram_post' => 'Telegram 投稿', - 'discord_message' => 'Discord メッセージ', - 'facebook_post' => 'Facebook 投稿', - 'pinterest_pin' => 'Pinterest ピン', - 'instagram_story' => 'Instagram ストーリー', - 'facebook_story' => 'Facebook ストーリー', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram フィード投稿', + 'instagram_carousel' => 'Instagram カルーセル', + 'linkedin_post' => 'LinkedIn 投稿', + 'linkedin_page_post' => 'LinkedIn ページ投稿', + 'x_post' => 'X 投稿', + 'bluesky_post' => 'Bluesky 投稿', + 'threads_post' => 'Threads 投稿', + 'mastodon_post' => 'Mastodon 投稿', + 'facebook_post' => 'Facebook 投稿', + 'pinterest_pin' => 'Pinterest ピン', + 'instagram_story' => 'Instagram ストーリー', ], ]; diff --git a/lang/ja/sidebar.php b/lang/ja/sidebar.php index 8f5febe56..d52c9d118 100644 --- a/lang/ja/sidebar.php +++ b/lang/ja/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'アナリティクス', 'automations' => 'オートメーション', - 'onboarding' => 'はじめに', - 'onboarding_hint' => 'セットアップを完了', + 'browse' => 'メニュー', + 'chat' => 'チャット', + 'chat_history' => '履歴', + 'new_chat' => '新しいチャット', + 'no_chats' => 'まだチャットはありません', + 'last_7_days' => '過去7日間', + 'last_30_days' => '過去30日間', + 'older' => 'それ以前', 'posts' => [ 'calendar' => 'カレンダー', 'all' => 'すべて', diff --git a/lang/ja/welcome.php b/lang/ja/welcome.php deleted file mode 100644 index 24eaac618..000000000 --- a/lang/ja/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'あなたに一番近いのは?', - 'description' => '近いものを選ぶと、体験を最適化します。', - 'continue' => '続ける', - 'subscription_required_title' => 'アカウントのオーナーを待っています', - 'subscription_required_description' => 'このアカウントにはまだ有効なサブスクリプションがありません。オーナーにチェックアウトの完了を依頼してください — 有効になり次第、フルアクセスできます。', - 'subscription_required_owner' => 'アカウントのオーナーは :name です。', - 'subscription_required_auto' => 'このページは自動で更新されます — 再読み込みは不要です。', - 'progress' => 'ようこそ進捗', - 'go_to_step' => 'ステップ :step へ', - 'step_current' => 'ステップ :step(現在)', - 'personas' => [ - 'creator' => 'コンテンツクリエイター', - 'freelancer' => 'フリーランス', - 'developer' => '開発者', - 'startup' => 'スタートアップ', - 'agency' => '代理店', - 'small_business' => '中小企業', - 'marketer' => 'マーケター', - 'online_store' => 'オンラインストア', - 'other' => 'その他', - ], - 'goals_title' => '目標は何ですか?', - 'goals_description' => '当てはまるものをすべて選んでください。TryPost をあなた向けに設定します。', - 'goals' => [ - 'save_time' => 'すべての場所へ一度に投稿して時間を節約する', - 'ai_content' => 'TryPost AI で投稿を生成する', - 'use_mcp' => 'Claude・ChatGPT・Cursor から投稿を作成する', - 'plan_calendar' => 'カレンダーで投稿を計画する', - 'stay_on_brand' => 'すべての投稿をブランドに沿ったものにする', - 'grow_audience' => 'オーディエンスとエンゲージメントを増やす', - 'drive_sales' => 'トラフィックと売上を増やす', - 'manage_clients' => '複数のブランドやクライアントを管理する', - 'just_exploring' => '今はまだ様子を見ている', - 'other' => 'その他', - ], - 'referral_source_title' => 'どこで私たちを知りましたか?', - 'referral_source_description' => 'これは、人々がどのように TryPost を見つけるかを理解するのに役立ちます。', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'ディレクトリ(G2、Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => '友人または同僚', - 'founder' => '創業者', - 'blog' => 'Blog / newsletter', - 'other' => 'その他', - ], - 'connect' => [ - 'title' => 'SNSアカウントを接続', - 'description' => 'TryPostが投稿できるネットワークを少なくとも1つ選んでください。', - 'required' => '続けるには、少なくとも1つのSNSアカウントを接続してください。', - ], -]; diff --git a/lang/ko/chat.php b/lang/ko/chat.php new file mode 100644 index 000000000..6634e43d3 --- /dev/null +++ b/lang/ko/chat.php @@ -0,0 +1,105 @@ + '채팅', + 'headline' => '게시물에 대해 무엇을 도와드릴까요?', + 'description' => '최근 게시물이나 성과를 물어보세요. Claude나 ChatGPT 없이 TryPost MCP와 대화하는 것과 같습니다.', + 'placeholder' => '게시물이나 지표에 대해 물어보세요', + 'send' => '보내기', + 'suggestions_label' => '이런 걸 물어보세요', + 'suggestions' => [ + 'posts' => '최근 게시물', + 'metrics' => '게시물 지표', + ], + 'thinking' => '생각 중…', + 'groups' => [ + 'today' => '오늘', + 'yesterday' => '어제', + 'last_7_days' => '지난 7일', + 'last_30_days' => '지난 30일', + 'older' => '이전', + ], + 'tools' => [ + 'post_not_found' => '게시물을 찾을 수 없습니다.', + 'error' => '문제가 발생했습니다. 다시 시도해 주세요.', + 'publish_no_enabled_platforms' => '이 게시물에는 활성화된 플랫폼이 없습니다. 게시하기 전에 최소 하나를 활성화하세요.', + 'delete_blocked' => '이 게시물은 이미 게시되어 TryPost에서 삭제할 수 없습니다. TikTok처럼 자동 삭제를 지원하지 않는 플랫폼에 게시되어 있다면 해당 플랫폼에서 직접 삭제해야 합니다.', + 'forbidden' => '이 워크스페이스에서 게시물을 만들거나 변경할 권한이 없습니다. 필요하다면 워크스페이스 관리자에게 요청하세요.', + ], + 'approvals' => [ + 'publish' => '지금 이 게시물을 게시할까요? 활성화된 모든 플랫폼에 즉시 게시되며, TikTok과 같은 일부 플랫폼은 이후 게시를 취소할 방법이 없습니다.', + 'delete_scheduled' => '이 게시물을 삭제하면 되돌릴 수 없습니다. 게시 예약이 되어 있다면 그 예약도 함께 취소됩니다. 그래도 삭제할까요?', + ], + 'errors' => [ + 'turn_in_progress' => '이 대화는 아직 응답 중입니다. 완료된 뒤에 다음 메시지를 보내세요.', + 'request_failed' => '메시지를 보내는 중 문제가 발생했습니다. 다시 시도해 주세요.', + 'stream_failed' => '응답하는 중 문제가 발생했습니다. 다시 시도해 주세요.', + 'payment_required_cta' => '결제 관리', + 'access_error' => '이 대화를 사용할 수 없습니다. 삭제되었거나 다른 사용자의 대화일 수 있습니다.', + ], + 'tool_names' => [ + 'list_posts' => '게시물 목록', + 'get_post' => '게시물 상세정보', + 'get_post_metrics' => '게시물 지표', + 'create_post' => '게시물 만들기', + 'update_post' => '게시물 업데이트', + 'schedule_post' => '게시물 예약', + 'publish_post' => '게시물 게시', + 'delete_post' => '게시물 삭제', + 'start_post_generation' => '게시물 옵션', + 'generate_post' => '게시물 생성', + ], + 'tool_card' => [ + 'running' => '실행 중…', + 'denied' => '이 작업을 거부했습니다.', + 'unknown_tool' => '어시스턴트가 이 화면에 아직 카드가 없는 도구를 사용했습니다.', + 'unreadable_result' => '이 결과를 읽을 수 없습니다.', + 'empty_list' => '게시물을 찾을 수 없습니다.', + 'open_in_editor' => '편집기에서 열기', + 'untitled' => '아직 내용이 없습니다.', + 'post_deleted' => '이 게시물은 삭제되었습니다.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => '아직 게시되지 않음', + 'platform_not_supported' => '이 플랫폼에서는 지표를 사용할 수 없습니다', + ], + 'empty' => '아직 표시할 지표가 없습니다.', + ], + 'approval' => [ + 'approve' => '승인', + 'reject' => '거부', + 'generic_reason' => '이 작업을 실행하려면 승인이 필요합니다.', + ], + 'post_generation' => [ + 'unavailable' => '이 워크스페이스에는 아직 연결된 계정이 없어 게시물을 생성할 형식이 없습니다. 먼저 계정을 연결하세요.', + 'styles_unavailable' => '지금은 사용할 수 있는 게시물 스타일이 없어 이 형식을 생성할 수단이 없습니다.', + 'format_question' => '어떤 형식으로 만들까요?', + 'style_question' => '어떤 스타일로 할까요?', + 'images_question' => '이미지는 몇 장으로 할까요?', + 'images_none' => '없음', + 'account_question' => '어떤 계정용인가요?', + 'topic_line' => '주제: :topic', + 'posting_to' => ':account 계정에 게시합니다', + 'brand_colors_label' => '브랜드 컬러', + 'brand_colors_description' => '생성되는 이미지에 워크스페이스의 브랜드 팔레트를 적용합니다.', + 'change' => '변경', + 'submit' => '게시물 생성', + 'sent' => '선택 내용을 보냈습니다.', + 'sentence' => ':account 계정으로 :topic에 대한 :format을 :style 스타일, :images로 생성해 주세요.', + 'sentence_with_brand' => ':account 계정으로 :topic에 대한 :format을 :style 스타일, :images, :brand로 생성해 주세요.', + 'sentence_images_none' => '이미지 없이', + 'sentence_images_one' => '이미지 1장', + 'sentence_images_other' => '이미지 :count장', + 'sentence_brand_on' => '브랜드 컬러 적용', + 'sentence_brand_off' => '브랜드 컬러 미적용', + 'result_waiting' => '게시물을 생성하는 중…', + 'result_elapsed_label' => '경과 시간: :elapsed', + 'result_waiting_hint' => '페이지를 벗어나도 계속 진행됩니다. 준비되면 여기에 게시물이 표시됩니다.', + 'result_detached_hint' => '백그라운드에서 계속 진행됩니다. 이 대화를 다시 열면 게시물을 확인할 수 있습니다.', + 'result_ready' => '게시물이 준비되었습니다.', + 'result_failed' => '게시물을 생성하지 못했습니다. 다시 요청해 재시도하세요.', + ], +]; diff --git a/lang/ko/onboarding.php b/lang/ko/onboarding.php index f9fb24008..44a8bb594 100644 --- a/lang/ko/onboarding.php +++ b/lang/ko/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '시작하기', - 'welcome' => 'TryPost에 오신 걸 환영해요, :name', - 'description' => '아래 단계를 따라 TryPost 사용법을 확인하고 첫 게시물을 발행하세요.', - 'skip_step' => '이 단계 건너뛰기', - 'continue' => 'TryPost로 계속', - 'status' => [ - 'complete' => '완료', - 'todo' => '할 일', - 'skipped' => '건너뜀', + 'title' => '무엇을 가장 잘 설명하나요?', + 'description' => '가장 가까운 항목을 선택하면 맞춤 경험을 제공해 드립니다.', + 'continue' => '계속', + 'subscription_required_title' => '계정 소유자를 기다리는 중', + 'subscription_required_description' => '이 계정에는 아직 활성 구독이 없습니다. 소유자에게 결제 완료를 요청하세요 — 활성화되는 즉시 모든 기능을 사용할 수 있습니다.', + 'subscription_required_owner' => '계정 소유자는 :name 님입니다.', + 'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.', + 'progress' => '온보딩 진행률', + 'go_to_step' => ':step단계로 이동', + 'step_current' => ':step단계 (현재)', + 'personas' => [ + 'creator' => '콘텐츠 크리에이터', + 'freelancer' => '프리랜서', + 'developer' => '개발자', + 'startup' => '스타트업', + 'agency' => '에이전시', + 'small_business' => '소상공인', + 'marketer' => '마케터', + 'online_store' => '온라인 스토어', + 'other' => '기타', ], - 'mcp' => [ - 'title' => 'AI 어시스턴트 연결', - 'description' => 'TryPost를 MCP 서버로 추가하면 어시스턴트가 소셜 게시물을 만들고 관리할 수 있어요.', - 'copied' => 'MCP URL이 복사되었어요.', + 'goals_title' => '목표가 무엇인가요?', + 'goals_description' => '가장 가까운 항목을 선택하면 TryPost를 맞춤 설정해 드립니다.', + 'goals' => [ + 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', + 'ai_content' => 'TryPost AI로 게시물 생성', + 'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성', + 'plan_calendar' => '캘린더에서 게시물 계획', + 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', + 'grow_audience' => '팔로워와 참여 늘리기', + 'drive_sales' => '더 많은 트래픽과 판매 유도', + 'manage_clients' => '여러 브랜드 또는 클라이언트 관리', + 'just_exploring' => '지금은 둘러보는 중', + 'other' => '다른 것', ], - 'social' => [ - 'title' => '소셜 계정 연결', - 'description' => 'TryPost가 콘텐츠를 발행할 네트워크를 하나 이상 선택하세요.', - 'connected_elsewhere' => '다른 워크스페이스에서 이미 계정을 연결했으므로 이 단계는 완료되었습니다.', + 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', + 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => '디렉터리 (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => '친구 또는 동료', + 'founder' => '창업자', + 'blog' => 'Blog / newsletter', + 'other' => '기타', ], - 'first_post' => [ - 'title' => '첫 게시물 만들기', - 'description' => '연결된 어시스턴트에서 이 시작 프롬프트를 써 보거나, TryPost에서 바로 게시물을 만드세요.', - 'prompt_label' => '샘플 프롬프트', - 'sample_prompt' => '내 브랜드를 소개하는 친근한 소셜 게시물을 만들고 연결된 각 네트워크에 맞게 조정해 주세요.', - 'copy_prompt' => '프롬프트 복사', - 'copied' => '샘플 프롬프트가 복사되었어요.', - 'create_button' => '첫 게시물 만들기', - 'or' => '또는', + 'connect' => [ + 'title' => '가장 많이 쓰는 소셜은 어디인가요?', + 'description' => '가장 자주 올리는 곳을 고르세요.', + 'follow_up' => ':network 계정을 연결하세요.', + 'latest_post' => '가장 최근 게시물입니다.', + 'pitch_views' => '{0} 이 게시물은 :network에서 :views회 봤습니다.|{1} 이 게시물은 :network에서 :views회 봤습니다.|[2,*] 이 게시물은 :network에서 :views회 봤습니다.', + 'pitch_no_views' => '이 게시물은 :network에만 올라갔습니다.', + 'pitch_missed' => '{0}|{1} 같은 게시물을 :first에도 올리면 :each회는 더 나올 수 있습니다. :extra명이 이걸 못 본 거예요.|[2,*] 같은 게시물을 :first와 :second에도 올리면 각각 :each회는 더 나올 수 있습니다. :extra명이 이걸 못 본 거예요.', + 'pitch_sales' => '매출 수천을 놓치고 있을 수 있어요.', + 'change_network' => '네트워크 바꾸기', + 'required' => '계속하려면 소셜 계정을 하나 이상 연결하세요.', ], - 'ready' => [ - 'title' => '발행할 준비가 됐어요', - 'description' => '설정이 끝났어요. TryPost로 가서 콘텐츠 계획을 시작하세요.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/ko/posts.php b/lang/ko/posts.php index 2ec32c9a4..4b1f84ec3 100644 --- a/lang/ko/posts.php +++ b/lang/ko/posts.php @@ -591,80 +591,18 @@ 'cancel' => '취소', ], - 'create' => [ - 'title' => '새 게시물 만들기', - 'description' => '어떻게 시작할지 선택하세요.', - 'scratch_title' => '처음부터 시작', - 'scratch_description' => '빈 게시물을 열고 직접 모든 것을 작성하세요.', - 'ai_title' => 'AI로 생성', - 'ai_description' => '원하는 것을 설명하면 AI가 콘텐츠를 생성해 드립니다.', - 'ai_configure_description' => '형식을 선택하고 만들고 싶은 게시물을 설명하세요.', - 'ai_pick_template_description' => 'AI가 생성할 게시물의 스타일을 선택하세요.', - 'coming_soon' => '곧 제공 예정', - - 'preview' => [ - 'image_title' => '이미지 제목', - 'image_body' => '이미지 본문', - ], - - 'steps' => [ - 'template_picker_title' => '스타일 선택', - 'format_title' => '형식 선택', - 'format_description' => '만들고 싶은 게시물 유형을 선택하세요.', - 'account_title' => '계정 선택', - 'account_description' => '게시할 소셜 계정을 선택하세요.', - 'media_title' => '미디어 옵션', - 'media_carousel' => '슬라이드는 몇 장인가요?', - 'media_optional' => '이미지를 포함할까요?', - 'media_optional_label' => '이미지는 몇 장인가요?', - 'media_none' => '없음', - 'media_count_label' => '이미지 수', - 'brand_colors_label' => '브랜드 색상', - 'brand_colors_description' => '생성되는 이미지에 워크스페이스 브랜드 색상을 적용합니다. 끄면 AI가 콘텐츠에 어울리는 색상을 선택합니다.', - 'prompt_title' => '게시물을 설명하세요', - 'prompt_label' => '이 게시물은 무엇에 관한 것인가요?', - 'prompt_placeholder' => '예: Instagram용 새 캐러셀 기능 발표', - 'preview_error' => '문제가 발생했습니다. 다시 시도해 주세요.', - 'loading_page_title' => '게시물을 생성하는 중', - 'loading_eta' => '예상 시간: 약 :minutes.', - 'loading_eta_minute_one' => '1분', - 'loading_eta_minute_other' => ':count분', - 'loading_leave_title' => '계속 작업하셔도 됩니다.', - 'loading_leave_body' => '게시물이 준비되면 알려드리겠습니다.', - 'loading_leave_cta' => '캘린더로 이동', - 'loading_create_another_cta' => '다른 게시물 만들기', - 'loading_tip_credits' => '각 AI 이미지는 약 15크레딧을 사용합니다.', - 'loading_tip_edit' => '게시물이 준비되면 모든 것을 편집할 수 있습니다.', - 'loading_tip_draft' => '생성된 게시물은 초안에 저장됩니다.', - 'loading_tip_brand' => '브랜드 설정을 조정하여 향후 게시물에 영향을 주세요.', - 'loading_tip_carousel' => '캐러셀은 업로드한 이미지당 슬라이드 하나를 제공합니다.', - 'loading_tip_quality' => '이미지 품질은 속도와 비용의 균형에 맞게 설정됩니다.', - 'create' => '게시물 만들기', - 'back' => '뒤로', - 'next' => '계속', - 'cancel' => '취소', - 'discard' => '버리기', - 'retry' => '다시 시도', - 'no_platforms' => '연결된 계정 없음', - 'connect_first' => 'AI 생성을 사용하려면 소셜 계정을 하나 이상 연결하세요.', - 'no_account_for_template' => '이 템플릿을 사용하려면 호환되는 계정을 연결하세요.', - - 'format' => [ - 'instagram_feed' => 'Instagram 피드 게시물', - 'instagram_carousel' => 'Instagram 캐러셀', - 'linkedin_post' => 'LinkedIn 게시물', - 'linkedin_page_post' => 'LinkedIn 페이지 게시물', - 'x_post' => 'X 게시물', - 'bluesky_post' => 'Bluesky 게시물', - 'threads_post' => 'Threads 게시물', - 'mastodon_post' => 'Mastodon 게시물', - 'telegram_post' => 'Telegram 게시물', - 'discord_message' => 'Discord 메시지', - 'facebook_post' => 'Facebook 게시물', - 'pinterest_pin' => 'Pinterest 핀', - 'instagram_story' => 'Instagram 스토리', - 'facebook_story' => 'Facebook 스토리', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram 피드 게시물', + 'instagram_carousel' => 'Instagram 캐러셀', + 'linkedin_post' => 'LinkedIn 게시물', + 'linkedin_page_post' => 'LinkedIn 페이지 게시물', + 'x_post' => 'X 게시물', + 'bluesky_post' => 'Bluesky 게시물', + 'threads_post' => 'Threads 게시물', + 'mastodon_post' => 'Mastodon 게시물', + 'facebook_post' => 'Facebook 게시물', + 'pinterest_pin' => 'Pinterest 핀', + 'instagram_story' => 'Instagram 스토리', ], ]; diff --git a/lang/ko/sidebar.php b/lang/ko/sidebar.php index ddc3c3096..a8b5f7136 100644 --- a/lang/ko/sidebar.php +++ b/lang/ko/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => '분석', 'automations' => '자동화', - 'onboarding' => '시작하기', - 'onboarding_hint' => '설정 마치기', + 'browse' => '메뉴', + 'chat' => '채팅', + 'chat_history' => '기록', + 'new_chat' => '새 채팅', + 'no_chats' => '아직 채팅이 없습니다', + 'last_7_days' => '최근 7일', + 'last_30_days' => '최근 30일', + 'older' => '이전', 'posts' => [ 'calendar' => '캘린더', 'all' => '전체', diff --git a/lang/ko/welcome.php b/lang/ko/welcome.php deleted file mode 100644 index 8156ae271..000000000 --- a/lang/ko/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - '무엇을 가장 잘 설명하나요?', - 'description' => '가장 가까운 항목을 선택하면 맞춤 경험을 제공해 드립니다.', - 'continue' => '계속', - 'subscription_required_title' => '계정 소유자를 기다리는 중', - 'subscription_required_description' => '이 계정에는 아직 활성 구독이 없습니다. 소유자에게 결제 완료를 요청하세요 — 활성화되는 즉시 모든 기능을 사용할 수 있습니다.', - 'subscription_required_owner' => '계정 소유자는 :name 님입니다.', - 'subscription_required_auto' => '이 페이지는 자동으로 업데이트됩니다 — 새로고침할 필요가 없습니다.', - 'progress' => '환영 진행률', - 'go_to_step' => ':step단계로 이동', - 'step_current' => ':step단계 (현재)', - 'personas' => [ - 'creator' => '콘텐츠 크리에이터', - 'freelancer' => '프리랜서', - 'developer' => '개발자', - 'startup' => '스타트업', - 'agency' => '에이전시', - 'small_business' => '소상공인', - 'marketer' => '마케터', - 'online_store' => '온라인 스토어', - 'other' => '기타', - ], - 'goals_title' => '목표가 무엇인가요?', - 'goals_description' => '해당되는 항목을 모두 선택하면 TryPost를 맞춤 설정해 드립니다.', - 'goals' => [ - 'save_time' => '한 번에 여러 곳에 게시하여 시간 절약', - 'ai_content' => 'TryPost AI로 게시물 생성', - 'use_mcp' => 'Claude, ChatGPT 또는 Cursor에서 게시물 작성', - 'plan_calendar' => '캘린더에서 게시물 계획', - 'stay_on_brand' => '모든 게시물을 브랜드에 맞게 유지', - 'grow_audience' => '팔로워와 참여 늘리기', - 'drive_sales' => '더 많은 트래픽과 판매 유도', - 'manage_clients' => '여러 브랜드 또는 클라이언트 관리', - 'just_exploring' => '지금은 둘러보는 중', - 'other' => '다른 것', - ], - 'referral_source_title' => '저희를 어떻게 알게 되셨나요?', - 'referral_source_description' => '사람들이 TryPost를 어떻게 발견하는지 파악하는 데 도움이 됩니다.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => '디렉터리 (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => '친구 또는 동료', - 'founder' => '창업자', - 'blog' => 'Blog / newsletter', - 'other' => '기타', - ], - 'connect' => [ - 'title' => '소셜 계정을 연결하세요', - 'description' => 'TryPost가 콘텐츠를 게시할 네트워크를 하나 이상 선택하세요.', - 'required' => '계속하려면 소셜 계정을 하나 이상 연결하세요.', - ], -]; diff --git a/lang/nl/chat.php b/lang/nl/chat.php new file mode 100644 index 000000000..8bfeabf53 --- /dev/null +++ b/lang/nl/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'Hoe kan ik helpen met je posts?', + 'description' => 'Vraag naar recente posts of hun prestaties — alsof je met de TryPost-MCP praat, zonder Claude of ChatGPT.', + 'placeholder' => 'Vraag naar posts of statistieken', + 'send' => 'Versturen', + 'suggestions_label' => 'Probeer te vragen naar', + 'suggestions' => [ + 'posts' => 'Recente posts', + 'metrics' => 'Poststatistieken', + ], + 'thinking' => 'Aan het nadenken…', + 'groups' => [ + 'today' => 'Vandaag', + 'yesterday' => 'Gisteren', + 'last_7_days' => 'Laatste 7 dagen', + 'last_30_days' => 'Laatste 30 dagen', + 'older' => 'Ouder', + ], + 'tools' => [ + 'post_not_found' => 'Post niet gevonden.', + 'error' => 'Er is iets misgegaan. Probeer het opnieuw.', + 'publish_no_enabled_platforms' => 'Deze post heeft geen geactiveerde platforms. Activeer er minstens één voordat je publiceert.', + 'delete_blocked' => 'Deze post is al gepubliceerd en kan niet uit TryPost worden verwijderd. Staat hij live op een platform dat geen automatische verwijdering ondersteunt, zoals TikTok, dan moet je hem daar handmatig verwijderen.', + 'forbidden' => 'Je hebt geen rechten om posts in deze workspace te maken of te wijzigen. Vraag er een workspace-beheerder om als je ze nodig hebt.', + ], + 'approvals' => [ + 'publish' => 'Deze post nu publiceren? Hij komt meteen live op elk geactiveerd platform, en sommige platforms — zoals TikTok — bieden achteraf geen manier om hem weer offline te halen.', + 'delete_scheduled' => 'Het verwijderen van deze post kan niet ongedaan worden gemaakt. Staat hij gepland om te publiceren, dan wordt dat hiermee ook geannuleerd. Toch verwijderen?', + ], + 'errors' => [ + 'turn_in_progress' => 'Dit gesprek is nog aan het antwoorden. Wacht tot het klaar is voordat je een nieuw bericht stuurt.', + 'request_failed' => 'Er is iets misgegaan bij het versturen van je bericht. Probeer het opnieuw.', + 'stream_failed' => 'Er is iets misgegaan tijdens het antwoorden. Probeer het opnieuw.', + 'payment_required_cta' => 'Facturering beheren', + 'access_error' => 'Dit gesprek is niet beschikbaar. Het is mogelijk verwijderd of behoort aan iemand anders toe.', + ], + 'tool_names' => [ + 'list_posts' => 'Posts weergeven', + 'get_post' => 'Postdetails', + 'get_post_metrics' => 'Poststatistieken', + 'create_post' => 'Post aanmaken', + 'update_post' => 'Post bijwerken', + 'schedule_post' => 'Post plannen', + 'publish_post' => 'Post publiceren', + 'delete_post' => 'Post verwijderen', + 'start_post_generation' => 'Postopties', + 'generate_post' => 'Post genereren', + ], + 'tool_card' => [ + 'running' => 'Bezig…', + 'denied' => 'Je hebt deze actie geweigerd.', + 'unknown_tool' => 'De assistent gebruikte een tool waar deze interface nog geen kaart voor heeft.', + 'unreadable_result' => 'Dit resultaat kon niet worden gelezen.', + 'empty_list' => 'Geen posts gevonden.', + 'open_in_editor' => 'Openen in editor', + 'untitled' => 'Nog geen inhoud.', + 'post_deleted' => 'Deze post is verwijderd.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Nog niet gepubliceerd', + 'platform_not_supported' => 'Statistieken zijn niet beschikbaar voor dit platform', + ], + 'empty' => 'Nog geen statistieken om te tonen.', + ], + 'approval' => [ + 'approve' => 'Goedkeuren', + 'reject' => 'Weigeren', + 'generic_reason' => 'Deze actie heeft jouw goedkeuring nodig voordat hij wordt uitgevoerd.', + ], + 'post_generation' => [ + 'unavailable' => 'Deze workspace heeft nog geen gekoppeld account, dus er is geen formaat om een post voor te genereren. Koppel eerst een account.', + 'styles_unavailable' => 'Er is op dit moment geen poststijl beschikbaar, dus er is niets om dit formaat mee te genereren.', + 'format_question' => 'Welk formaat moet ik maken?', + 'style_question' => 'Welke stijl moet het krijgen?', + 'images_question' => 'Hoeveel afbeeldingen wil je?', + 'images_none' => 'Geen', + 'account_question' => 'Voor welk account is dit?', + 'topic_line' => 'Over: :topic', + 'posting_to' => 'Plaatsen op :account', + 'brand_colors_label' => 'Merkkleuren', + 'brand_colors_description' => 'Pas het merkpalet van je workspace toe op de gegenereerde afbeeldingen.', + 'change' => 'Wijzigen', + 'submit' => 'Post genereren', + 'sent' => 'Keuzes verstuurd.', + 'sentence' => 'Genereer :format over :topic, stijl :style, :images, op :account.', + 'sentence_with_brand' => 'Genereer :format over :topic, stijl :style, :images, op :account, :brand.', + 'sentence_images_none' => 'zonder afbeeldingen', + 'sentence_images_one' => '1 afbeelding', + 'sentence_images_other' => ':count afbeeldingen', + 'sentence_brand_on' => 'met merkkleuren', + 'sentence_brand_off' => 'zonder merkkleuren', + 'result_waiting' => 'Je post wordt gegenereerd…', + 'result_elapsed_label' => 'Verstreken tijd: :elapsed', + 'result_waiting_hint' => 'Dit loopt door als je de pagina verlaat. De post verschijnt hier zodra hij klaar is.', + 'result_detached_hint' => 'Dit loopt door op de achtergrond. Open dit gesprek opnieuw om de post te zien.', + 'result_ready' => 'Je post is klaar.', + 'result_failed' => 'De post kon niet worden gegenereerd. Vraag het opnieuw om het nog eens te proberen.', + ], +]; diff --git a/lang/nl/onboarding.php b/lang/nl/onboarding.php index 025029e4a..b6e9f8f4c 100644 --- a/lang/nl/onboarding.php +++ b/lang/nl/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'Wat omschrijft jou het beste?', + 'description' => 'Kies de dichtstbijzijnde optie, dan stemmen we je ervaring af.', + 'continue' => 'Doorgaan', + 'subscription_required_title' => 'Wachten op de accounteigenaar', + 'subscription_required_description' => 'Dit account heeft nog geen actief abonnement. Vraag de eigenaar om de checkout af te ronden — je krijgt volledige toegang zodra het actief is.', + 'subscription_required_owner' => 'De accounteigenaar is :name.', + 'subscription_required_auto' => 'Deze pagina vernieuwt automatisch — verversen is niet nodig.', + 'progress' => 'Onboardingvoortgang', + 'go_to_step' => 'Ga naar stap :step', + 'step_current' => 'Stap :step (huidig)', + 'personas' => [ + 'creator' => 'Contentmaker', + 'freelancer' => 'Freelancer', + 'developer' => 'Ontwikkelaar', + 'startup' => 'Startup', + 'agency' => 'Bureau', + 'small_business' => 'Klein bedrijf', + 'marketer' => 'Marketeer', + 'online_store' => 'Webshop', + 'other' => 'Anders', ], - '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.', + 'goals_title' => 'Wat is je doel?', + 'goals_description' => 'Kies de optie die het beste past, dan stellen we TryPost voor je in.', + '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', + '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', + 'drive_sales' => 'Meer verkeer en verkopen krijgen', + 'manage_clients' => 'Meerdere merken of klanten beheren', + 'just_exploring' => 'Voorlopig gewoon aan het verkennen', + 'other' => 'Iets anders', ], - '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.', + 'referral_source_title' => 'Hoe heb je ons gevonden?', + 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Directory (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Vriend of collega', + 'founder' => 'Oprichter', + 'blog' => 'Blog / newsletter', + 'other' => 'Iets anders', ], - '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', + 'connect' => [ + 'title' => 'Welk netwerk gebruik je het meest?', + 'description' => 'Kies het netwerk waarop je het meest post.', + 'follow_up' => 'Verbind je :network-account.', + 'latest_post' => 'Dit is je laatste post.', + 'pitch_views' => '{0} Deze post had :views views op :network.|{1} Deze post had :views view op :network.|[2,*] Deze post had :views views op :network.', + 'pitch_no_views' => 'Deze post ging alleen naar :network.', + 'pitch_missed' => '{0}|{1} Dezelfde post op :first kan zomaar nog :each views erbij krijgen. Dat zijn :extra mensen die het nooit zagen.|[2,*] Dezelfde post op :first en :second kan zomaar nog :each views per netwerk erbij krijgen. Dat zijn :extra mensen die het nooit zagen.', + 'pitch_sales' => 'Je laat misschien duizenden aan sales liggen.', + 'change_network' => 'Ander netwerk', + 'required' => 'Verbind minstens één social account om door te gaan.', ], - 'ready' => [ - 'title' => 'Je bent klaar om te publiceren', - 'description' => 'Alles staat. Ga door naar TryPost en begin je content te plannen.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/nl/posts.php b/lang/nl/posts.php index 2e06df707..d54de2702 100644 --- a/lang/nl/posts.php +++ b/lang/nl/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Annuleren', ], - 'create' => [ - 'title' => 'Een nieuwe post maken', - 'description' => 'Kies hoe je wilt beginnen.', - 'scratch_title' => 'Vanaf nul beginnen', - 'scratch_description' => 'Open een lege post en schrijf alles zelf.', - 'ai_title' => 'Genereren met AI', - 'ai_description' => 'Beschrijf wat je wilt en AI genereert de inhoud voor je.', - 'ai_configure_description' => 'Kies een formaat en beschrijf de post die je wilt maken.', - 'ai_pick_template_description' => 'Kies een stijl voor je AI-gegenereerde post.', - 'coming_soon' => 'Binnenkort beschikbaar', - - 'preview' => [ - 'image_title' => 'Afbeeldingstitel', - 'image_body' => 'Afbeeldingstekst', - ], - - 'steps' => [ - 'template_picker_title' => 'Kies een stijl', - 'format_title' => 'Kies een formaat', - 'format_description' => 'Selecteer het type post dat je wilt maken.', - 'account_title' => 'Kies een account', - 'account_description' => 'Selecteer het social account om naar te publiceren.', - 'media_title' => 'Media-opties', - 'media_carousel' => 'Hoeveel slides?', - 'media_optional' => 'Afbeeldingen toevoegen?', - 'media_optional_label' => 'Hoeveel afbeeldingen?', - 'media_none' => 'Geen', - 'media_count_label' => 'Aantal afbeeldingen', - 'brand_colors_label' => 'Merkkleuren', - 'brand_colors_description' => 'Pas het merkkleurenpalet van je workspace toe op de gegenereerde afbeeldingen. Zet het uit om de AI kleuren te laten kiezen die bij de inhoud passen.', - 'prompt_title' => 'Beschrijf je post', - 'prompt_label' => 'Waar gaat deze post over?', - 'prompt_placeholder' => 'bijv. Kondig onze nieuwe carrouselfunctie voor Instagram aan', - 'preview_error' => 'Er is iets misgegaan. Probeer het opnieuw.', - 'loading_page_title' => 'Je post wordt gegenereerd', - 'loading_eta' => 'Geschatte tijd: ongeveer :minutes.', - 'loading_eta_minute_one' => '1 minuut', - 'loading_eta_minute_other' => ':count minuten', - 'loading_leave_title' => 'Je kunt gewoon doorwerken.', - 'loading_leave_body' => 'We laten het je weten wanneer de post klaar is.', - 'loading_leave_cta' => 'Ga naar kalender', - 'loading_create_another_cta' => 'Nog een post maken', - 'loading_tip_credits' => 'Elke AI-afbeelding gebruikt ongeveer 15 credits.', - 'loading_tip_edit' => 'Je kunt alles bewerken zodra de post klaar is.', - 'loading_tip_draft' => 'Gegenereerde posts komen in je concepten terecht.', - 'loading_tip_brand' => 'Pas je merkinstellingen aan om toekomstige posts te beïnvloeden.', - 'loading_tip_carousel' => 'Carrousels leveren één slide per geüploade afbeelding.', - 'loading_tip_quality' => 'De beeldkwaliteit is ingesteld op een balans tussen snelheid en kosten.', - 'create' => 'Post aanmaken', - 'back' => 'Terug', - 'next' => 'Doorgaan', - 'cancel' => 'Annuleren', - 'discard' => 'Verwijderen', - 'retry' => 'Opnieuw proberen', - 'no_platforms' => 'Geen gekoppelde accounts', - 'connect_first' => 'Koppel ten minste één social account om AI-generatie te gebruiken.', - 'no_account_for_template' => 'Koppel een compatibel account om dit sjabloon te gebruiken.', - - 'format' => [ - 'instagram_feed' => 'Instagram-feedpost', - 'instagram_carousel' => 'Instagram-carrousel', - 'linkedin_post' => 'LinkedIn-post', - 'linkedin_page_post' => 'LinkedIn-paginapost', - 'x_post' => 'X-post', - 'bluesky_post' => 'Bluesky-post', - 'threads_post' => 'Threads-post', - 'mastodon_post' => 'Mastodon-post', - 'telegram_post' => 'Telegram-post', - 'discord_message' => 'Discord-bericht', - 'facebook_post' => 'Facebook-post', - 'pinterest_pin' => 'Pinterest-pin', - 'instagram_story' => 'Instagram-story', - 'facebook_story' => 'Facebook-story', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram-feedpost', + 'instagram_carousel' => 'Instagram-carrousel', + 'linkedin_post' => 'LinkedIn-post', + 'linkedin_page_post' => 'LinkedIn-paginapost', + 'x_post' => 'X-post', + 'bluesky_post' => 'Bluesky-post', + 'threads_post' => 'Threads-post', + 'mastodon_post' => 'Mastodon-post', + 'facebook_post' => 'Facebook-post', + 'pinterest_pin' => 'Pinterest-pin', + 'instagram_story' => 'Instagram-story', ], ]; diff --git a/lang/nl/sidebar.php b/lang/nl/sidebar.php index 212a3c99b..f40c01b46 100644 --- a/lang/nl/sidebar.php +++ b/lang/nl/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Statistieken', 'automations' => 'Automatiseringen', - 'onboarding' => 'Aan de slag', - 'onboarding_hint' => 'Setup afronden', + 'browse' => 'Menu', + 'chat' => 'Chat', + 'chat_history' => 'Geschiedenis', + 'new_chat' => 'Nieuwe chat', + 'no_chats' => 'Nog geen chats', + 'last_7_days' => 'Laatste 7 dagen', + 'last_30_days' => 'Laatste 30 dagen', + 'older' => 'Ouder', 'posts' => [ 'calendar' => 'Kalender', 'all' => 'Alle', diff --git a/lang/nl/welcome.php b/lang/nl/welcome.php deleted file mode 100644 index f6250a224..000000000 --- a/lang/nl/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Wat omschrijft jou het beste?', - 'description' => 'Kies de dichtstbijzijnde optie, dan stemmen we je ervaring af.', - 'continue' => 'Doorgaan', - 'subscription_required_title' => 'Wachten op de accounteigenaar', - 'subscription_required_description' => 'Dit account heeft nog geen actief abonnement. Vraag de eigenaar om de checkout af te ronden — je krijgt volledige toegang zodra het actief is.', - '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)', - 'personas' => [ - 'creator' => 'Contentmaker', - 'freelancer' => 'Freelancer', - 'developer' => 'Ontwikkelaar', - 'startup' => 'Startup', - 'agency' => 'Bureau', - 'small_business' => 'Klein bedrijf', - 'marketer' => 'Marketeer', - 'online_store' => 'Webshop', - 'other' => 'Anders', - ], - 'goals_title' => 'Wat is je doel?', - 'goals_description' => 'Kies alles wat past en we stellen TryPost voor je in.', - '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', - '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', - 'drive_sales' => 'Meer verkeer en verkopen krijgen', - 'manage_clients' => 'Meerdere merken of klanten beheren', - 'just_exploring' => 'Voorlopig gewoon aan het verkennen', - 'other' => 'Iets anders', - ], - 'referral_source_title' => 'Hoe heb je ons gevonden?', - 'referral_source_description' => 'Dit helpt ons te begrijpen hoe mensen TryPost ontdekken.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Directory (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Vriend of collega', - 'founder' => 'Oprichter', - 'blog' => 'Blog / newsletter', - 'other' => 'Iets anders', - ], - 'connect' => [ - 'title' => 'Verbind een social account', - 'description' => 'Kies minstens één netwerk waarop TryPost je content kan plaatsen.', - 'required' => 'Verbind minstens één social account om door te gaan.', - ], -]; diff --git a/lang/pl/chat.php b/lang/pl/chat.php new file mode 100644 index 000000000..65fb82d74 --- /dev/null +++ b/lang/pl/chat.php @@ -0,0 +1,105 @@ + 'Czat', + 'headline' => 'W czym mogę pomóc przy Twoich postach?', + 'description' => 'Zapytaj o ostatnie posty lub ich wyniki — jak rozmowa z MCP TryPost, bez Claude’a i ChatGPT.', + 'placeholder' => 'Zapytaj o posty lub statystyki', + 'send' => 'Wyślij', + 'suggestions_label' => 'Spróbuj zapytać o', + 'suggestions' => [ + 'posts' => 'Ostatnie posty', + 'metrics' => 'Statystyki postów', + ], + 'thinking' => 'Myślę…', + 'groups' => [ + 'today' => 'Dziś', + 'yesterday' => 'Wczoraj', + 'last_7_days' => 'Ostatnie 7 dni', + 'last_30_days' => 'Ostatnie 30 dni', + 'older' => 'Starsze', + ], + 'tools' => [ + 'post_not_found' => 'Nie znaleziono posta.', + 'error' => 'Coś poszło nie tak. Spróbuj ponownie.', + 'publish_no_enabled_platforms' => 'Ten post nie ma żadnej włączonej platformy. Włącz co najmniej jedną przed publikacją.', + 'delete_blocked' => 'Ten post został już opublikowany i nie można go usunąć z TryPost. Jeśli jest widoczny na platformie, która nie obsługuje automatycznego usuwania, np. TikTok, trzeba go usunąć tam ręcznie.', + 'forbidden' => 'Nie masz uprawnień do tworzenia ani zmieniania postów w tej przestrzeni roboczej. Poproś o nie administratora przestrzeni roboczej, jeśli ich potrzebujesz.', + ], + 'approvals' => [ + 'publish' => 'Opublikować ten post teraz? Pojawi się natychmiast na każdej włączonej platformie, a niektóre platformy — jak TikTok — nie dają możliwości cofnięcia publikacji.', + 'delete_scheduled' => 'Usunięcia tego posta nie da się cofnąć. Jeśli jest zaplanowany do publikacji, to również anuluje ten plan. Usunąć mimo to?', + ], + 'errors' => [ + 'turn_in_progress' => 'Ta rozmowa wciąż generuje odpowiedź. Poczekaj, aż się zakończy, zanim wyślesz kolejną wiadomość.', + 'request_failed' => 'Coś poszło nie tak podczas wysyłania wiadomości. Spróbuj ponownie.', + 'stream_failed' => 'Coś poszło nie tak podczas odpowiadania. Spróbuj ponownie.', + 'payment_required_cta' => 'Zarządzaj płatnościami', + 'access_error' => 'Ta rozmowa jest niedostępna. Mogła zostać usunięta lub należy do kogoś innego.', + ], + 'tool_names' => [ + 'list_posts' => 'Lista postów', + 'get_post' => 'Szczegóły posta', + 'get_post_metrics' => 'Statystyki posta', + 'create_post' => 'Utwórz post', + 'update_post' => 'Zaktualizuj post', + 'schedule_post' => 'Zaplanuj post', + 'publish_post' => 'Opublikuj post', + 'delete_post' => 'Usuń post', + 'start_post_generation' => 'Opcje posta', + 'generate_post' => 'Wygeneruj post', + ], + 'tool_card' => [ + 'running' => 'Trwa wykonywanie…', + 'denied' => 'Odrzucono tę akcję.', + 'unknown_tool' => 'Asystent użył narzędzia, dla którego ten interfejs nie ma jeszcze karty.', + 'unreadable_result' => 'Nie można było odczytać tego wyniku.', + 'empty_list' => 'Nie znaleziono postów.', + 'open_in_editor' => 'Otwórz w edytorze', + 'untitled' => 'Brak treści.', + 'post_deleted' => 'Ten post został usunięty.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Jeszcze nieopublikowany', + 'platform_not_supported' => 'Statystyki nie są dostępne dla tej platformy', + ], + 'empty' => 'Brak statystyk do wyświetlenia.', + ], + 'approval' => [ + 'approve' => 'Zatwierdź', + 'reject' => 'Odrzuć', + 'generic_reason' => 'Ta akcja wymaga Twojej zgody, zanim zostanie wykonana.', + ], + 'post_generation' => [ + 'unavailable' => 'Ten workspace nie ma jeszcze połączonego konta, więc nie ma formatu, dla którego można wygenerować post. Najpierw połącz konto.', + 'styles_unavailable' => 'Obecnie nie jest dostępny żaden styl posta, więc nie ma czym wygenerować tego formatu.', + 'format_question' => 'Jaki format mam wygenerować?', + 'style_question' => 'Jakiego stylu mam użyć?', + 'images_question' => 'Ile obrazów ma mieć?', + 'images_none' => 'Brak', + 'account_question' => 'Dla którego konta to jest?', + 'topic_line' => 'Temat: :topic', + 'posting_to' => 'Publikacja na :account', + 'brand_colors_label' => 'Kolory marki', + 'brand_colors_description' => 'Zastosuj paletę marki swojego workspace do generowanych obrazów.', + 'change' => 'Zmień', + 'submit' => 'Wygeneruj post', + 'sent' => 'Wybory wysłane.', + 'sentence' => 'Wygeneruj :format o :topic, styl :style, :images, na :account.', + 'sentence_with_brand' => 'Wygeneruj :format o :topic, styl :style, :images, na :account, :brand.', + 'sentence_images_none' => 'bez obrazów', + 'sentence_images_one' => '1 obraz', + 'sentence_images_other' => ':count obrazów', + 'sentence_brand_on' => 'w kolorach marki', + 'sentence_brand_off' => 'bez kolorów marki', + 'result_waiting' => 'Generuję Twój post…', + 'result_elapsed_label' => 'Upłynęło: :elapsed', + 'result_waiting_hint' => 'To działa dalej, nawet jeśli opuścisz stronę. Post pojawi się tutaj, gdy będzie gotowy.', + 'result_detached_hint' => 'To działa dalej w tle. Otwórz tę rozmowę ponownie, aby zobaczyć post.', + 'result_ready' => 'Twój post jest gotowy.', + 'result_failed' => 'Nie udało się wygenerować posta. Poproś jeszcze raz, aby spróbować ponownie.', + ], +]; diff --git a/lang/pl/onboarding.php b/lang/pl/onboarding.php index f2c2e7933..e93c0c021 100644 --- a/lang/pl/onboarding.php +++ b/lang/pl/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'Co najlepiej Cię opisuje?', + 'description' => 'Wybierz najbliższą opcję, a my dopasujemy Twoje doświadczenie.', + 'continue' => 'Kontynuuj', + 'subscription_required_title' => 'Oczekiwanie na właściciela konta', + 'subscription_required_description' => 'To konto nie ma jeszcze aktywnej subskrypcji. Poproś właściciela o dokończenie płatności — uzyskasz pełny dostęp, gdy tylko będzie aktywna.', + '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 onboardingu', + 'go_to_step' => 'Przejdź do kroku :step', + 'step_current' => 'Krok :step (bieżący)', + 'personas' => [ + 'creator' => 'Twórca treści', + 'freelancer' => 'Freelancer', + 'developer' => 'Programista', + 'startup' => 'Startup', + 'agency' => 'Agencja', + 'small_business' => 'Mała firma', + 'marketer' => 'Marketingowiec', + 'online_store' => 'Sklep internetowy', + 'other' => 'Inne', ], - '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.', + 'goals_title' => 'Jaki jest Twój cel?', + 'goals_description' => 'Wybierz tę, która pasuje najbardziej, a my skonfigurujemy TryPost dla Ciebie.', + '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', + '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', + 'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży', + 'manage_clients' => 'Zarządzaj wieloma markami lub klientami', + 'just_exploring' => 'Na razie tylko się rozglądam', + 'other' => 'Coś innego', ], - '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.', + 'referral_source_title' => 'Jak nas znalazłeś?', + 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Katalog (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Znajomy lub współpracownik', + 'founder' => 'Założyciel', + 'blog' => 'Blog / newsletter', + 'other' => 'Coś innego', ], - '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', + 'connect' => [ + 'title' => 'Z której sieci korzystasz najczęściej?', + 'description' => 'Wybierz tę, na której najczęściej publikujesz.', + 'follow_up' => 'Połącz konto :network.', + 'latest_post' => 'Oto twój najnowszy post.', + 'pitch_views' => '{0} Ten post miał :views wyświetleń na :network.|{1} Ten post miał :views wyświetlenie na :network.|[2,*] Ten post miał :views wyświetleń na :network.', + 'pitch_no_views' => 'Ten post poszedł tylko na :network.', + 'pitch_missed' => '{0}|{1} Ten sam post na :first może łatwo dostać jeszcze :each wyświetleń. To :extra osób, które go nie widziały.|[2,*] Ten sam post na :first i :second może łatwo dostać jeszcze po :each wyświetleń. To :extra osób, które go nie widziały.', + 'pitch_sales' => 'Możesz tracić tysiące sprzedaży.', + 'change_network' => 'Zmień sieć', + 'required' => 'Połącz co najmniej jedno konto społecznościowe, aby kontynuować.', ], - 'ready' => [ - 'title' => 'Możesz już publikować', - 'description' => 'Wszystko gotowe. Przejdź do TryPost i zacznij planować treści.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/pl/posts.php b/lang/pl/posts.php index d47721c06..1d7635e96 100644 --- a/lang/pl/posts.php +++ b/lang/pl/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Anuluj', ], - 'create' => [ - 'title' => 'Utwórz nowy post', - 'description' => 'Wybierz, jak chcesz zacząć.', - 'scratch_title' => 'Zacznij od zera', - 'scratch_description' => 'Otwórz pusty post i napisz wszystko samodzielnie.', - 'ai_title' => 'Wygeneruj z AI', - 'ai_description' => 'Opisz, czego chcesz, a AI wygeneruje treść za Ciebie.', - 'ai_configure_description' => 'Wybierz format i opisz post, który chcesz utworzyć.', - 'ai_pick_template_description' => 'Wybierz styl dla swojego posta wygenerowanego przez AI.', - 'coming_soon' => 'Wkrótce', - - 'preview' => [ - 'image_title' => 'Tytuł obrazu', - 'image_body' => 'Treść obrazu', - ], - - 'steps' => [ - 'template_picker_title' => 'Wybierz styl', - 'format_title' => 'Wybierz format', - 'format_description' => 'Wybierz typ posta, który chcesz utworzyć.', - 'account_title' => 'Wybierz konto', - 'account_description' => 'Wybierz konto społecznościowe do publikacji.', - 'media_title' => 'Opcje multimediów', - 'media_carousel' => 'Ile slajdów?', - 'media_optional' => 'Dołączyć obrazy?', - 'media_optional_label' => 'Ile obrazów?', - 'media_none' => 'Brak', - 'media_count_label' => 'Liczba obrazów', - 'brand_colors_label' => 'Kolory marki', - 'brand_colors_description' => 'Zastosuj paletę marki Twojego obszaru roboczego do generowanych obrazów. Wyłącz, aby AI dobrała kolory pasujące do treści.', - 'prompt_title' => 'Opisz swój post', - 'prompt_label' => 'O czym jest ten post?', - 'prompt_placeholder' => 'np. Ogłoś naszą nową funkcję karuzeli na Instagramie', - 'preview_error' => 'Coś poszło nie tak. Spróbuj ponownie.', - 'loading_page_title' => 'Generowanie Twojego posta', - 'loading_eta' => 'Szacowany czas: około :minutes.', - 'loading_eta_minute_one' => '1 minuta', - 'loading_eta_minute_other' => ':count minut', - 'loading_leave_title' => 'Możesz dalej pracować.', - 'loading_leave_body' => 'Powiadomimy Cię, gdy post będzie gotowy.', - 'loading_leave_cta' => 'Przejdź do kalendarza', - 'loading_create_another_cta' => 'Utwórz kolejny post', - 'loading_tip_credits' => 'Każdy obraz AI zużywa około 15 kredytów.', - 'loading_tip_edit' => 'Będziesz mógł edytować wszystko, gdy post będzie gotowy.', - 'loading_tip_draft' => 'Wygenerowane posty trafiają do Twoich szkiców.', - 'loading_tip_brand' => 'Dostosuj ustawienia swojej marki, aby wpłynąć na przyszłe posty.', - 'loading_tip_carousel' => 'Karuzele tworzą jeden slajd na każdy przesłany obraz.', - 'loading_tip_quality' => 'Jakość obrazu jest ustawiona tak, aby równoważyć szybkość i koszt.', - 'create' => 'Utwórz post', - 'back' => 'Wstecz', - 'next' => 'Kontynuuj', - 'cancel' => 'Anuluj', - 'discard' => 'Odrzuć', - 'retry' => 'Spróbuj ponownie', - 'no_platforms' => 'Brak połączonych kont', - 'connect_first' => 'Połącz co najmniej jedno konto społecznościowe, aby korzystać z generowania AI.', - 'no_account_for_template' => 'Połącz kompatybilne konto, aby użyć tego szablonu.', - - 'format' => [ - 'instagram_feed' => 'Post na feedzie Instagram', - 'instagram_carousel' => 'Karuzela na Instagramie', - 'linkedin_post' => 'Post na LinkedIn', - 'linkedin_page_post' => 'Post na stronie LinkedIn', - 'x_post' => 'Post na X', - 'bluesky_post' => 'Post na Bluesky', - 'threads_post' => 'Post na Threads', - 'mastodon_post' => 'Post na Mastodon', - 'telegram_post' => 'Post na Telegramie', - 'discord_message' => 'Wiadomość na Discord', - 'facebook_post' => 'Post na Facebooku', - 'pinterest_pin' => 'Pin na Pinterest', - 'instagram_story' => 'Relacja na Instagramie', - 'facebook_story' => 'Relacja na Facebooku', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Post na feedzie Instagram', + 'instagram_carousel' => 'Karuzela na Instagramie', + 'linkedin_post' => 'Post na LinkedIn', + 'linkedin_page_post' => 'Post na stronie LinkedIn', + 'x_post' => 'Post na X', + 'bluesky_post' => 'Post na Bluesky', + 'threads_post' => 'Post na Threads', + 'mastodon_post' => 'Post na Mastodon', + 'facebook_post' => 'Post na Facebooku', + 'pinterest_pin' => 'Pin na Pinterest', + 'instagram_story' => 'Relacja na Instagramie', ], ]; diff --git a/lang/pl/sidebar.php b/lang/pl/sidebar.php index f3bed7b84..51b2c19d3 100644 --- a/lang/pl/sidebar.php +++ b/lang/pl/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analityka', 'automations' => 'Automatyzacje', - 'onboarding' => 'Pierwsze kroki', - 'onboarding_hint' => 'Dokończ konfigurację', + 'browse' => 'Menu', + 'chat' => 'Czat', + 'chat_history' => 'Historia', + 'new_chat' => 'Nowy czat', + 'no_chats' => 'Brak czatów', + 'last_7_days' => 'Ostatnie 7 dni', + 'last_30_days' => 'Ostatnie 30 dni', + 'older' => 'Starsze', 'posts' => [ 'calendar' => 'Kalendarz', 'all' => 'Wszystkie', diff --git a/lang/pl/welcome.php b/lang/pl/welcome.php deleted file mode 100644 index 3dbb28db9..000000000 --- a/lang/pl/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Co najlepiej Cię opisuje?', - 'description' => 'Wybierz najbliższą opcję, a my dopasujemy Twoje doświadczenie.', - 'continue' => 'Kontynuuj', - 'subscription_required_title' => 'Oczekiwanie na właściciela konta', - 'subscription_required_description' => 'To konto nie ma jeszcze aktywnej subskrypcji. Poproś właściciela o dokończenie płatności — uzyskasz pełny dostęp, gdy tylko będzie aktywna.', - '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)', - 'personas' => [ - 'creator' => 'Twórca treści', - 'freelancer' => 'Freelancer', - 'developer' => 'Programista', - 'startup' => 'Startup', - 'agency' => 'Agencja', - 'small_business' => 'Mała firma', - 'marketer' => 'Marketingowiec', - 'online_store' => 'Sklep internetowy', - 'other' => 'Inne', - ], - 'goals_title' => 'Jaki jest Twój cel?', - 'goals_description' => 'Wybierz wszystko, co pasuje, a my skonfigurujemy TryPost dla Ciebie.', - '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', - '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', - 'drive_sales' => 'Zdobywaj więcej ruchu i sprzedaży', - 'manage_clients' => 'Zarządzaj wieloma markami lub klientami', - 'just_exploring' => 'Na razie tylko się rozglądam', - 'other' => 'Coś innego', - ], - 'referral_source_title' => 'Jak nas znalazłeś?', - 'referral_source_description' => 'To pomaga nam zrozumieć, jak ludzie odkrywają TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Katalog (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Znajomy lub współpracownik', - 'founder' => 'Założyciel', - 'blog' => 'Blog / newsletter', - 'other' => 'Coś innego', - ], - 'connect' => [ - 'title' => 'Połącz konto społecznościowe', - 'description' => 'Wybierz co najmniej jedną sieć, na której TryPost może publikować Twoje treści.', - 'required' => 'Połącz co najmniej jedno konto społecznościowe, aby kontynuować.', - ], -]; diff --git a/lang/pt-BR/chat.php b/lang/pt-BR/chat.php new file mode 100644 index 000000000..1351bb191 --- /dev/null +++ b/lang/pt-BR/chat.php @@ -0,0 +1,105 @@ + 'Chat', + 'headline' => 'Como posso ajudar com os seus posts?', + 'description' => 'Pergunte sobre os posts recentes ou o desempenho deles — como falar com o MCP do TryPost, sem Claude ou ChatGPT.', + 'placeholder' => 'Pergunte sobre posts ou métricas', + 'send' => 'Enviar', + 'suggestions_label' => 'Experimente perguntar sobre', + 'suggestions' => [ + 'posts' => 'Posts recentes', + 'metrics' => 'Métricas dos posts', + ], + 'thinking' => 'Pensando…', + 'groups' => [ + 'today' => 'Hoje', + 'yesterday' => 'Ontem', + 'last_7_days' => 'Últimos 7 dias', + 'last_30_days' => 'Últimos 30 dias', + 'older' => 'Mais antigos', + ], + 'tools' => [ + 'post_not_found' => 'Post não encontrado.', + 'error' => 'Algo deu errado. Tente novamente.', + 'publish_no_enabled_platforms' => 'Este post não tem nenhuma plataforma habilitada. Habilite pelo menos uma antes de publicar.', + 'delete_blocked' => 'Este post já foi publicado e não pode ser excluído do TryPost. Se ele estiver no ar em uma plataforma que não oferece remoção automática, como o TikTok, você precisará excluí-lo por lá manualmente.', + 'forbidden' => 'Você não tem permissão para criar ou alterar posts neste workspace. Peça acesso a um administrador do workspace se precisar.', + ], + 'approvals' => [ + 'publish' => 'Publicar este post agora? Ele ficará no ar imediatamente em todas as plataformas habilitadas, e algumas — como o TikTok — não oferecem forma de despublicar depois.', + 'delete_scheduled' => 'Excluir este post não pode ser desfeito. Se ele estiver agendado para publicar, isso também cancela o agendamento. Excluir mesmo assim?', + ], + 'errors' => [ + 'turn_in_progress' => 'Esta conversa ainda está respondendo. Aguarde ela terminar antes de enviar outra mensagem.', + 'request_failed' => 'Algo deu errado ao enviar sua mensagem. Tente novamente.', + 'stream_failed' => 'Algo deu errado ao responder. Tente novamente.', + 'payment_required_cta' => 'Gerenciar cobrança', + 'access_error' => 'Esta conversa não está disponível. Ela pode ter sido excluída ou pertence a outra pessoa.', + ], + 'tool_names' => [ + 'list_posts' => 'Listar posts', + 'get_post' => 'Detalhes do post', + 'get_post_metrics' => 'Métricas do post', + 'create_post' => 'Criar post', + 'update_post' => 'Atualizar post', + 'schedule_post' => 'Agendar post', + 'publish_post' => 'Publicar post', + 'delete_post' => 'Excluir post', + 'start_post_generation' => 'Opções de post', + 'generate_post' => 'Gerar post', + ], + 'tool_card' => [ + 'running' => 'Executando…', + 'denied' => 'Você recusou esta ação.', + 'unknown_tool' => 'O assistente usou uma ferramenta para a qual esta interface ainda não tem um cartão.', + 'unreadable_result' => 'Não foi possível ler este resultado.', + 'empty_list' => 'Nenhum post encontrado.', + 'open_in_editor' => 'Abrir no editor', + 'untitled' => 'Ainda sem conteúdo.', + 'post_deleted' => 'Este post foi excluído.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Ainda não publicado', + 'platform_not_supported' => 'As métricas não estão disponíveis para esta plataforma', + ], + 'empty' => 'Ainda não há métricas para mostrar.', + ], + 'approval' => [ + 'approve' => 'Aprovar', + 'reject' => 'Recusar', + 'generic_reason' => 'Esta ação precisa da sua aprovação antes de ser executada.', + ], + 'post_generation' => [ + 'unavailable' => 'Este workspace ainda não tem nenhuma conta conectada, então não há formato para gerar um post. Conecte uma conta primeiro.', + 'styles_unavailable' => 'Nenhum estilo de post está disponível no momento, então não há como gerar este formato.', + 'format_question' => 'Qual formato eu gero?', + 'style_question' => 'Qual estilo devo usar?', + 'images_question' => 'Quantas imagens você quer?', + 'images_none' => 'Nenhuma', + 'account_question' => 'Para qual conta é?', + 'topic_line' => 'Sobre: :topic', + 'posting_to' => 'Publicando em :account', + 'brand_colors_label' => 'Cores da marca', + 'brand_colors_description' => 'Aplica a paleta da sua marca nas imagens geradas.', + 'change' => 'Alterar', + 'submit' => 'Gerar post', + 'sent' => 'Escolhas enviadas.', + 'sentence' => 'Gerar :format sobre :topic, estilo :style, :images, em :account.', + 'sentence_with_brand' => 'Gerar :format sobre :topic, estilo :style, :images, em :account, :brand.', + 'sentence_images_none' => 'sem imagens', + 'sentence_images_one' => '1 imagem', + 'sentence_images_other' => ':count imagens', + 'sentence_brand_on' => 'com as cores da marca', + 'sentence_brand_off' => 'sem as cores da marca', + 'result_waiting' => 'Gerando seu post…', + 'result_elapsed_label' => 'Tempo decorrido: :elapsed', + 'result_waiting_hint' => 'Isso continua rodando mesmo se você sair. O post aparece aqui assim que estiver pronto.', + 'result_detached_hint' => 'Isso continua rodando em segundo plano. Reabra esta conversa para ver o post.', + 'result_ready' => 'Seu post está pronto.', + 'result_failed' => 'Não foi possível gerar o post. Peça de novo para tentar outra vez.', + ], +]; diff --git a/lang/pt-BR/onboarding.php b/lang/pt-BR/onboarding.php index d55a848c9..49298e591 100644 --- a/lang/pt-BR/onboarding.php +++ b/lang/pt-BR/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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', + 'title' => 'O que melhor descreve você?', + 'description' => 'Escolha a opção mais próxima e a gente personaliza sua experiência.', + 'continue' => 'Continuar', + 'subscription_required_title' => 'Aguardando o dono da conta', + 'subscription_required_description' => 'Esta conta ainda não tem uma assinatura ativa. Peça ao dono da conta para concluir o checkout — você terá acesso total assim que ela estiver ativa.', + 'subscription_required_owner' => 'O dono da sua conta é :name.', + 'subscription_required_auto' => 'Esta página atualiza automaticamente — não precisa recarregar.', + 'progress' => 'Progresso do onboarding', + 'go_to_step' => 'Ir para a etapa :step', + 'step_current' => 'Etapa :step (atual)', + 'personas' => [ + 'creator' => 'Criador de conteúdo', + 'freelancer' => 'Freelancer', + 'developer' => 'Desenvolvedor', + 'startup' => 'Startup', + 'agency' => 'Agência', + 'small_business' => 'Pequena empresa', + 'marketer' => 'Profissional de marketing', + 'online_store' => 'Loja online', + 'other' => 'Outro', ], - '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.', + 'goals_title' => 'Qual o seu objetivo?', + 'goals_description' => 'Escolha a que mais faz sentido e a gente ajusta o TryPost pra você.', + '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', + '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', + 'drive_sales' => 'Conseguir mais tráfego e vendas', + 'manage_clients' => 'Gerenciar várias marcas ou clientes', + 'just_exploring' => 'Só dando uma olhada por enquanto', + 'other' => 'Outra coisa', ], - '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.', + 'referral_source_title' => 'Como você nos encontrou?', + 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Diretório (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Amigo ou colega', + 'founder' => 'Fundador', + 'blog' => 'Blog / newsletter', + 'other' => 'Outra coisa', ], - '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', + 'connect' => [ + 'title' => 'Qual rede social você mais usa?', + 'description' => 'Escolhe a que você mais publica.', + 'follow_up' => 'Conecte sua conta do :network.', + 'latest_post' => 'Este é o seu post mais recente.', + 'pitch_views' => '{0} Esse post teve :views views no :network.|{1} Esse post teve :views view no :network.|[2,*] Esse post teve :views views no :network.', + 'pitch_no_views' => 'Esse post só foi pro :network.', + 'pitch_missed' => '{0}|{1} O mesmo post no :first podia ter mais :each views. São :extra pessoas que não viram isso.|[2,*] O mesmo post no :first e no :second podia ter mais :each views em cada. São :extra pessoas que não viram isso.', + 'pitch_sales' => 'Dá pra estar perdendo milhares em vendas.', + 'change_network' => 'Trocar rede', + 'required' => 'Conecte pelo menos uma rede social para continuar.', ], - 'ready' => [ - 'title' => 'Tudo pronto pra publicar', - 'description' => 'Você já pode seguir. Continue no TryPost e comece a planejar seu conteúdo.', + 'publish_method' => [ + 'title' => 'Você vai agendar os seus posts manualmente ou usando IA?', + 'description' => 'Manualmente, você escreve e agenda aqui no TryPost. Com IA, o Claude, o ChatGPT ou o Cursor publicam pra você.', + 'manual' => 'Manualmente', + 'ai' => 'Com IA', + 'mcp' => 'Conecte o Claude, ChatGPT ou Cursor pra publicar por aqui.', + 'connected' => 'Conectou com sucesso.', + 'connected_description' => ':name já tá ligado no TryPost.', + 'required' => 'Escolha se você vai agendar manualmente ou com IA.', ], ]; diff --git a/lang/pt-BR/posts.php b/lang/pt-BR/posts.php index 3e4c0a0ec..6274a9044 100644 --- a/lang/pt-BR/posts.php +++ b/lang/pt-BR/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Cancelar', ], - 'create' => [ - 'title' => 'Criar novo post', - 'description' => 'Escolha como quer começar.', - 'scratch_title' => 'Começar do zero', - 'scratch_description' => 'Abre um post em branco pra você escrever tudo.', - 'ai_title' => 'Gerar com IA', - 'ai_description' => 'Descreva o que quer e a IA gera o conteúdo pra você.', - 'ai_configure_description' => 'Escolha o formato e descreva o post que quer criar.', - 'ai_pick_template_description' => 'Escolha um estilo para o seu post gerado por IA.', - 'coming_soon' => 'Em breve', - - 'preview' => [ - 'image_title' => 'Título da imagem', - 'image_body' => 'Texto da imagem', - ], - - 'steps' => [ - 'template_picker_title' => 'Escolha um estilo', - 'format_title' => 'Escolha um formato', - 'format_description' => 'Selecione o tipo de post que deseja criar.', - 'account_title' => 'Escolha uma conta', - 'account_description' => 'Selecione a conta social para publicar.', - 'media_title' => 'Opções de mídia', - 'media_carousel' => 'Quantos slides?', - 'media_optional' => 'Incluir imagens?', - 'media_optional_label' => 'Quantas imagens?', - 'media_none' => 'Nenhuma', - 'media_count_label' => 'Número de imagens', - 'brand_colors_label' => 'Cores da marca', - 'brand_colors_description' => 'Aplica a paleta da sua marca nas imagens geradas. Desative para deixar a IA escolher as cores que combinam com o conteúdo.', - 'prompt_title' => 'Descreva seu post', - 'prompt_label' => 'Sobre o que é este post?', - 'prompt_placeholder' => 'Ex. Anunciar nossa nova função de carrossel para o Instagram', - 'preview_error' => 'Algo deu errado. Por favor, tente novamente.', - 'loading_page_title' => 'Gerando seu post', - 'loading_eta' => 'Tempo estimado: cerca de :minutes.', - 'loading_eta_minute_one' => '1 minuto', - 'loading_eta_minute_other' => ':count minutos', - 'loading_leave_title' => 'Você pode continuar trabalhando.', - 'loading_leave_body' => 'A gente te avisa assim que o post ficar pronto.', - 'loading_leave_cta' => 'Ir pro calendário', - 'loading_create_another_cta' => 'Criar outro post', - 'loading_tip_credits' => 'Cada imagem AI consome cerca de 15 créditos.', - 'loading_tip_edit' => 'Você poderá editar tudo quando o post ficar pronto.', - 'loading_tip_draft' => 'Posts gerados vão direto pros seus rascunhos.', - 'loading_tip_brand' => 'Ajuste sua marca pra influenciar os próximos posts.', - 'loading_tip_carousel' => 'Carrosséis geram um slide por imagem solicitada.', - 'loading_tip_quality' => 'A qualidade equilibra velocidade e custo.', - 'create' => 'Criar post', - 'back' => 'Voltar', - 'next' => 'Continuar', - 'cancel' => 'Cancelar', - 'discard' => 'Descartar', - 'retry' => 'Tentar novamente', - 'no_platforms' => 'Nenhuma conta conectada', - 'connect_first' => 'Conecte pelo menos uma conta social para usar a geração com IA.', - 'no_account_for_template' => 'Conecte uma conta compatível para usar este template.', - - 'format' => [ - 'instagram_feed' => 'Post no Feed do Instagram', - 'instagram_carousel' => 'Carrossel do Instagram', - 'linkedin_post' => 'Post no LinkedIn', - 'linkedin_page_post' => 'Post em Página do LinkedIn', - 'x_post' => 'Post no X', - 'bluesky_post' => 'Post no Bluesky', - 'threads_post' => 'Post no Threads', - 'mastodon_post' => 'Post no Mastodon', - 'telegram_post' => 'Post no Telegram', - 'discord_message' => 'Mensagem do Discord', - 'facebook_post' => 'Post no Facebook', - 'pinterest_pin' => 'Pin no Pinterest', - 'instagram_story' => 'Story do Instagram', - 'facebook_story' => 'Story do Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Post no Feed do Instagram', + 'instagram_carousel' => 'Carrossel do Instagram', + 'linkedin_post' => 'Post no LinkedIn', + 'linkedin_page_post' => 'Post em Página do LinkedIn', + 'x_post' => 'Post no X', + 'bluesky_post' => 'Post no Bluesky', + 'threads_post' => 'Post no Threads', + 'mastodon_post' => 'Post no Mastodon', + 'facebook_post' => 'Post no Facebook', + 'pinterest_pin' => 'Pin no Pinterest', + 'instagram_story' => 'Story do Instagram', ], ]; diff --git a/lang/pt-BR/sidebar.php b/lang/pt-BR/sidebar.php index ed7f13a81..2bc366bfa 100644 --- a/lang/pt-BR/sidebar.php +++ b/lang/pt-BR/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analytics', 'automations' => 'Automações', - 'onboarding' => 'Primeiros passos', - 'onboarding_hint' => 'Complete a configuração', + 'browse' => 'Menu', + 'chat' => 'Chat', + 'chat_history' => 'Histórico', + 'new_chat' => 'Novo chat', + 'no_chats' => 'Nenhum chat ainda', + 'last_7_days' => 'Últimos 7 dias', + 'last_30_days' => 'Últimos 30 dias', + 'older' => 'Mais antigos', 'posts' => [ 'calendar' => 'Calendário', 'all' => 'Todos', diff --git a/lang/pt-BR/welcome.php b/lang/pt-BR/welcome.php deleted file mode 100644 index 6cca5b662..000000000 --- a/lang/pt-BR/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'O que melhor descreve você?', - 'description' => 'Escolha a opção mais próxima e a gente personaliza sua experiência.', - 'continue' => 'Continuar', - 'subscription_required_title' => 'Aguardando o dono da conta', - 'subscription_required_description' => 'Esta conta ainda não tem uma assinatura ativa. Peça ao dono da conta para concluir o checkout — você terá acesso total assim que ela estiver ativa.', - '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)', - 'personas' => [ - 'creator' => 'Criador de conteúdo', - 'freelancer' => 'Freelancer', - 'developer' => 'Desenvolvedor', - 'startup' => 'Startup', - 'agency' => 'Agência', - 'small_business' => 'Pequena empresa', - 'marketer' => 'Profissional de marketing', - 'online_store' => 'Loja online', - 'other' => 'Outro', - ], - 'goals_title' => 'Qual o seu objetivo?', - 'goals_description' => 'Marque tudo que faz sentido e a gente ajusta o TryPost pra você.', - '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', - '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', - 'drive_sales' => 'Conseguir mais tráfego e vendas', - 'manage_clients' => 'Gerenciar várias marcas ou clientes', - 'just_exploring' => 'Só dando uma olhada por enquanto', - 'other' => 'Outra coisa', - ], - 'referral_source_title' => 'Como você nos encontrou?', - 'referral_source_description' => 'Isso nos ajuda a entender como as pessoas descobrem o TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Diretório (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Amigo ou colega', - 'founder' => 'Fundador', - 'blog' => 'Blog / newsletter', - 'other' => 'Outra coisa', - ], - 'connect' => [ - 'title' => 'Conecte uma rede social', - 'description' => 'Escolha pelo menos uma rede onde o TryPost possa publicar seu conteúdo.', - 'required' => 'Conecte pelo menos uma rede social para continuar.', - ], -]; diff --git a/lang/ru/chat.php b/lang/ru/chat.php new file mode 100644 index 000000000..252221328 --- /dev/null +++ b/lang/ru/chat.php @@ -0,0 +1,105 @@ + 'Чат', + 'headline' => 'Чем помочь с вашими постами?', + 'description' => 'Спросите про недавние посты или их результаты — как разговор с MCP TryPost, без Claude и ChatGPT.', + 'placeholder' => 'Спросите про посты или метрики', + 'send' => 'Отправить', + 'suggestions_label' => 'Попробуйте спросить про', + 'suggestions' => [ + 'posts' => 'Недавние посты', + 'metrics' => 'Метрики постов', + ], + 'thinking' => 'Думаю…', + 'groups' => [ + 'today' => 'Сегодня', + 'yesterday' => 'Вчера', + 'last_7_days' => 'Последние 7 дней', + 'last_30_days' => 'Последние 30 дней', + 'older' => 'Ранее', + ], + 'tools' => [ + 'post_not_found' => 'Пост не найден.', + 'error' => 'Что-то пошло не так. Попробуйте ещё раз.', + 'publish_no_enabled_platforms' => 'У этого поста нет ни одной включённой платформы. Включите хотя бы одну перед публикацией.', + 'delete_blocked' => 'Этот пост уже опубликован, и его нельзя удалить из TryPost. Если он опубликован на платформе, которая не поддерживает автоматическое удаление, например TikTok, удалите его там вручную.', + 'forbidden' => 'У вас нет прав создавать или изменять посты в этом рабочем пространстве. Попросите их у администратора рабочего пространства, если они нужны.', + ], + 'approvals' => [ + 'publish' => 'Опубликовать этот пост сейчас? Он сразу появится на всех включённых платформах, а некоторые из них — например TikTok — не дают возможности отменить публикацию потом.', + 'delete_scheduled' => 'Удаление этого поста нельзя отменить. Если он запланирован к публикации, это также отменит публикацию. Всё равно удалить?', + ], + 'errors' => [ + 'turn_in_progress' => 'Этот разговор ещё отвечает. Дождитесь завершения, прежде чем отправлять следующее сообщение.', + 'request_failed' => 'Не удалось отправить сообщение. Попробуйте ещё раз.', + 'stream_failed' => 'Во время ответа что-то пошло не так. Попробуйте ещё раз.', + 'payment_required_cta' => 'Управление оплатой', + 'access_error' => 'Этот разговор недоступен. Возможно, он был удалён или принадлежит другому пользователю.', + ], + 'tool_names' => [ + 'list_posts' => 'Список постов', + 'get_post' => 'Информация о посте', + 'get_post_metrics' => 'Метрики поста', + 'create_post' => 'Создать пост', + 'update_post' => 'Обновить пост', + 'schedule_post' => 'Запланировать пост', + 'publish_post' => 'Опубликовать пост', + 'delete_post' => 'Удалить пост', + 'start_post_generation' => 'Параметры поста', + 'generate_post' => 'Сгенерировать пост', + ], + 'tool_card' => [ + 'running' => 'Выполняется…', + 'denied' => 'Вы отклонили это действие.', + 'unknown_tool' => 'Ассистент использовал инструмент, для которого в этом интерфейсе пока нет карточки.', + 'unreadable_result' => 'Не удалось прочитать этот результат.', + 'empty_list' => 'Посты не найдены.', + 'open_in_editor' => 'Открыть в редакторе', + 'untitled' => 'Пока нет содержимого.', + 'post_deleted' => 'Этот пост удалён.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Ещё не опубликован', + 'platform_not_supported' => 'Метрики недоступны для этой платформы', + ], + 'empty' => 'Пока нет метрик для отображения.', + ], + 'approval' => [ + 'approve' => 'Одобрить', + 'reject' => 'Отклонить', + 'generic_reason' => 'Это действие требует вашего одобрения перед выполнением.', + ], + 'post_generation' => [ + 'unavailable' => 'В этом рабочем пространстве ещё нет подключённых аккаунтов, поэтому нет формата, для которого можно сгенерировать пост. Сначала подключите аккаунт.', + 'styles_unavailable' => 'Сейчас нет ни одного стиля поста, поэтому этот формат нечем сгенерировать.', + 'format_question' => 'Какой формат создать?', + 'style_question' => 'Какой стиль использовать?', + 'images_question' => 'Сколько изображений нужно?', + 'images_none' => 'Без изображений', + 'account_question' => 'Для какого аккаунта это?', + 'topic_line' => 'Тема: :topic', + 'posting_to' => 'Публикация в :account', + 'brand_colors_label' => 'Цвета бренда', + 'brand_colors_description' => 'Применить палитру бренда рабочего пространства к сгенерированным изображениям.', + 'change' => 'Изменить', + 'submit' => 'Сгенерировать пост', + 'sent' => 'Выбор отправлен.', + 'sentence' => 'Сгенерируй :format о :topic, стиль :style, :images, для :account.', + 'sentence_with_brand' => 'Сгенерируй :format о :topic, стиль :style, :images, для :account, :brand.', + 'sentence_images_none' => 'без изображений', + 'sentence_images_one' => '1 изображение', + 'sentence_images_other' => ':count изображений', + 'sentence_brand_on' => 'с цветами бренда', + 'sentence_brand_off' => 'без цветов бренда', + 'result_waiting' => 'Генерирую ваш пост…', + 'result_elapsed_label' => 'Прошло: :elapsed', + 'result_waiting_hint' => 'Это продолжится, даже если вы уйдёте со страницы. Пост появится здесь, как только будет готов.', + 'result_detached_hint' => 'Это продолжается в фоновом режиме. Откройте этот разговор заново, чтобы увидеть пост.', + 'result_ready' => 'Ваш пост готов.', + 'result_failed' => 'Не удалось сгенерировать пост. Попросите ещё раз, чтобы повторить попытку.', + ], +]; diff --git a/lang/ru/onboarding.php b/lang/ru/onboarding.php index 35d1c763e..263f982fb 100644 --- a/lang/ru/onboarding.php +++ b/lang/ru/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => 'Начало работы', - 'welcome' => 'Добро пожаловать в TryPost, :name', - 'description' => 'Выполните шаги ниже, чтобы увидеть, как работает TryPost, и опубликовать первый пост.', - 'skip_step' => 'Пропустить этот шаг', - 'continue' => 'Перейти в TryPost', - 'status' => [ - 'complete' => 'Готово', - 'todo' => 'Сделать', - 'skipped' => 'Пропущено', + 'title' => 'Что лучше всего вас описывает?', + 'description' => 'Выберите ближайший вариант — мы настроим опыт под вас.', + 'continue' => 'Продолжить', + 'subscription_required_title' => 'Ожидание владельца аккаунта', + 'subscription_required_description' => 'У этого аккаунта пока нет активной подписки. Попросите владельца завершить оплату — вы получите полный доступ сразу после её активации.', + 'subscription_required_owner' => 'Владелец вашего аккаунта — :name.', + 'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.', + 'progress' => 'Прогресс онбординга', + 'go_to_step' => 'Перейти к шагу :step', + 'step_current' => 'Шаг :step (текущий)', + 'personas' => [ + 'creator' => 'Автор контента', + 'freelancer' => 'Фрилансер', + 'developer' => 'Разработчик', + 'startup' => 'Стартап', + 'agency' => 'Агентство', + 'small_business' => 'Малый бизнес', + 'marketer' => 'Маркетолог', + 'online_store' => 'Интернет-магазин', + 'other' => 'Другое', ], - 'mcp' => [ - 'title' => 'Подключите ИИ-ассистента', - 'description' => 'Добавьте TryPost как MCP-сервер, чтобы ассистент мог создавать и вести соцпосты за вас.', - 'copied' => 'URL MCP скопирован.', + 'goals_title' => 'Какова ваша цель?', + 'goals_description' => 'Выберите то, что подходит лучше всего, и мы настроим TryPost для вас.', + 'goals' => [ + 'save_time' => 'Экономить время, публикуя всюду сразу', + 'ai_content' => 'Генерировать посты с ИИ TryPost', + 'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor', + 'plan_calendar' => 'Планировать посты в календаре', + 'stay_on_brand' => 'Держать каждый пост в стиле бренда', + 'grow_audience' => 'Наращивать аудиторию и вовлечённость', + 'drive_sales' => 'Получать больше трафика и продаж', + 'manage_clients' => 'Управлять несколькими брендами или клиентами', + 'just_exploring' => 'Пока просто знакомлюсь', + 'other' => 'Что-то ещё', ], - 'social' => [ - 'title' => 'Подключите соцсеть', - 'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.', - 'connected_elsewhere' => 'Вы уже подключили аккаунт в другом пространстве, так что этот шаг выполнен.', + 'referral_source_title' => 'Как вы нас нашли?', + 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Каталог (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Друг или коллега', + 'founder' => 'Основатель', + 'blog' => 'Blog / newsletter', + 'other' => 'Что-то другое', ], - 'first_post' => [ - 'title' => 'Создайте первый пост', - 'description' => 'Попробуйте этот стартовый промпт с подключённым ассистентом или создайте пост прямо в TryPost.', - 'prompt_label' => 'Пример промпта', - 'sample_prompt' => 'Создай дружелюбный соцпост с представлением моего бренда и адаптируй его для каждой подключённой сети.', - 'copy_prompt' => 'Копировать промпт', - 'copied' => 'Пример промпта скопирован.', - 'create_button' => 'Создать первый пост', - 'or' => 'или', + 'connect' => [ + 'title' => 'Какой соцсетью вы пользуетесь чаще всего?', + 'description' => 'Выберите ту, где вы публикуете больше всего.', + 'follow_up' => 'Подключите аккаунт :network.', + 'latest_post' => 'Вот ваш последний пост.', + 'pitch_views' => '{0} У этого поста :views просмотров в :network.|{1} У этого поста :views просмотр в :network.|[2,*] У этого поста :views просмотров в :network.', + 'pitch_no_views' => 'Этот пост вышел только в :network.', + 'pitch_missed' => '{0}|{1} Тот же пост в :first легко наберёт ещё :each просмотров. Это :extra человек, которые его не видели.|[2,*] Тот же пост в :first и :second легко наберёт ещё по :each просмотров. Это :extra человек, которые его не видели.', + 'pitch_sales' => 'Вы можете терять тысячи продаж.', + 'change_network' => 'Сменить сеть', + 'required' => 'Подключите хотя бы одну соцсеть, чтобы продолжить.', ], - 'ready' => [ - 'title' => 'Вы готовы публиковать', - 'description' => 'Всё настроено. Перейдите в TryPost и начните планировать контент.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/ru/posts.php b/lang/ru/posts.php index 19439375f..07901a3d0 100644 --- a/lang/ru/posts.php +++ b/lang/ru/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Отмена', ], - 'create' => [ - 'title' => 'Создать новый пост', - 'description' => 'Выберите, с чего хотите начать.', - 'scratch_title' => 'Начать с нуля', - 'scratch_description' => 'Откройте пустой пост и напишите всё сами.', - 'ai_title' => 'Сгенерировать с помощью ИИ', - 'ai_description' => 'Опишите, что хотите, и ИИ сгенерирует контент за вас.', - 'ai_configure_description' => 'Выберите формат и опишите пост, который хотите создать.', - 'ai_pick_template_description' => 'Выберите стиль для вашего ИИ-поста.', - 'coming_soon' => 'Скоро', - - 'preview' => [ - 'image_title' => 'Заголовок изображения', - 'image_body' => 'Текст изображения', - ], - - 'steps' => [ - 'template_picker_title' => 'Выберите стиль', - 'format_title' => 'Выберите формат', - 'format_description' => 'Выберите тип поста, который хотите создать.', - 'account_title' => 'Выберите аккаунт', - 'account_description' => 'Выберите социальный аккаунт для публикации.', - 'media_title' => 'Параметры медиа', - 'media_carousel' => 'Сколько слайдов?', - 'media_optional' => 'Добавить изображения?', - 'media_optional_label' => 'Сколько изображений?', - 'media_none' => 'Нет', - 'media_count_label' => 'Количество изображений', - 'brand_colors_label' => 'Цвета бренда', - 'brand_colors_description' => 'Применяйте фирменную палитру вашего рабочего пространства к создаваемым изображениям. Отключите, чтобы ИИ подобрал цвета под содержание.', - 'prompt_title' => 'Опишите свой пост', - 'prompt_label' => 'О чём этот пост?', - 'prompt_placeholder' => 'например, Анонсировать нашу новую функцию карусели для Instagram', - 'preview_error' => 'Что-то пошло не так. Попробуйте ещё раз.', - 'loading_page_title' => 'Генерируем ваш пост', - 'loading_eta' => 'Примерное время: около :minutes.', - 'loading_eta_minute_one' => 'минуты', - 'loading_eta_minute_other' => ':count минут', - 'loading_leave_title' => 'Вы можете продолжать работать.', - 'loading_leave_body' => 'Мы уведомим вас, когда пост будет готов.', - 'loading_leave_cta' => 'Перейти в календарь', - 'loading_create_another_cta' => 'Создать ещё один пост', - 'loading_tip_credits' => 'Каждое ИИ-изображение расходует около 15 кредитов.', - 'loading_tip_edit' => 'Вы сможете отредактировать всё, когда пост будет готов.', - 'loading_tip_draft' => 'Сгенерированные посты попадают в черновики.', - 'loading_tip_brand' => 'Настройте параметры бренда, чтобы влиять на будущие посты.', - 'loading_tip_carousel' => 'В карусели создаётся один слайд на каждое загруженное изображение.', - 'loading_tip_quality' => 'Качество изображений настроено на баланс скорости и стоимости.', - 'create' => 'Создать пост', - 'back' => 'Назад', - 'next' => 'Продолжить', - 'cancel' => 'Отмена', - 'discard' => 'Отменить', - 'retry' => 'Попробовать снова', - 'no_platforms' => 'Нет подключённых аккаунтов', - 'connect_first' => 'Подключите хотя бы один социальный аккаунт, чтобы использовать генерацию ИИ.', - 'no_account_for_template' => 'Подключите совместимый аккаунт, чтобы использовать этот шаблон.', - - 'format' => [ - 'instagram_feed' => 'Пост в ленте Instagram', - 'instagram_carousel' => 'Карусель Instagram', - 'linkedin_post' => 'Пост LinkedIn', - 'linkedin_page_post' => 'Пост страницы LinkedIn', - 'x_post' => 'Пост X', - 'bluesky_post' => 'Пост Bluesky', - 'threads_post' => 'Пост Threads', - 'mastodon_post' => 'Пост Mastodon', - 'telegram_post' => 'Пост Telegram', - 'discord_message' => 'Сообщение Discord', - 'facebook_post' => 'Пост Facebook', - 'pinterest_pin' => 'Пин Pinterest', - 'instagram_story' => 'История Instagram', - 'facebook_story' => 'История Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Пост в ленте Instagram', + 'instagram_carousel' => 'Карусель Instagram', + 'linkedin_post' => 'Пост LinkedIn', + 'linkedin_page_post' => 'Пост страницы LinkedIn', + 'x_post' => 'Пост X', + 'bluesky_post' => 'Пост Bluesky', + 'threads_post' => 'Пост Threads', + 'mastodon_post' => 'Пост Mastodon', + 'facebook_post' => 'Пост Facebook', + 'pinterest_pin' => 'Пин Pinterest', + 'instagram_story' => 'История Instagram', ], ]; diff --git a/lang/ru/sidebar.php b/lang/ru/sidebar.php index eeedb76fc..27ade6519 100644 --- a/lang/ru/sidebar.php +++ b/lang/ru/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Аналитика', 'automations' => 'Автоматизации', - 'onboarding' => 'Начало работы', - 'onboarding_hint' => 'Завершите настройку', + 'browse' => 'Меню', + 'chat' => 'Чат', + 'chat_history' => 'История', + 'new_chat' => 'Новый чат', + 'no_chats' => 'Пока нет чатов', + 'last_7_days' => 'Последние 7 дней', + 'last_30_days' => 'Последние 30 дней', + 'older' => 'Ранее', 'posts' => [ 'calendar' => 'Календарь', 'all' => 'Все', diff --git a/lang/ru/welcome.php b/lang/ru/welcome.php deleted file mode 100644 index 4dad73b90..000000000 --- a/lang/ru/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Что лучше всего вас описывает?', - 'description' => 'Выберите ближайший вариант — мы настроим опыт под вас.', - 'continue' => 'Продолжить', - 'subscription_required_title' => 'Ожидание владельца аккаунта', - 'subscription_required_description' => 'У этого аккаунта пока нет активной подписки. Попросите владельца завершить оплату — вы получите полный доступ сразу после её активации.', - 'subscription_required_owner' => 'Владелец вашего аккаунта — :name.', - 'subscription_required_auto' => 'Эта страница обновляется автоматически — перезагружать не нужно.', - 'progress' => 'Прогресс приветствия', - 'go_to_step' => 'Перейти к шагу :step', - 'step_current' => 'Шаг :step (текущий)', - 'personas' => [ - 'creator' => 'Автор контента', - 'freelancer' => 'Фрилансер', - 'developer' => 'Разработчик', - 'startup' => 'Стартап', - 'agency' => 'Агентство', - 'small_business' => 'Малый бизнес', - 'marketer' => 'Маркетолог', - 'online_store' => 'Интернет-магазин', - 'other' => 'Другое', - ], - 'goals_title' => 'Какова ваша цель?', - 'goals_description' => 'Выберите всё, что подходит, и мы настроим TryPost для вас.', - 'goals' => [ - 'save_time' => 'Экономить время, публикуя всюду сразу', - 'ai_content' => 'Генерировать посты с ИИ TryPost', - 'use_mcp' => 'Создавать посты через Claude, ChatGPT или Cursor', - 'plan_calendar' => 'Планировать посты в календаре', - 'stay_on_brand' => 'Держать каждый пост в стиле бренда', - 'grow_audience' => 'Наращивать аудиторию и вовлечённость', - 'drive_sales' => 'Получать больше трафика и продаж', - 'manage_clients' => 'Управлять несколькими брендами или клиентами', - 'just_exploring' => 'Пока просто знакомлюсь', - 'other' => 'Что-то ещё', - ], - 'referral_source_title' => 'Как вы нас нашли?', - 'referral_source_description' => 'Это помогает нам понять, как люди узнают о TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Каталог (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Друг или коллега', - 'founder' => 'Основатель', - 'blog' => 'Blog / newsletter', - 'other' => 'Что-то другое', - ], - 'connect' => [ - 'title' => 'Подключите соцсеть', - 'description' => 'Выберите хотя бы одну сеть, где TryPost сможет публиковать ваш контент.', - 'required' => 'Подключите хотя бы одну соцсеть, чтобы продолжить.', - ], -]; diff --git a/lang/tr/chat.php b/lang/tr/chat.php new file mode 100644 index 000000000..4b3d14826 --- /dev/null +++ b/lang/tr/chat.php @@ -0,0 +1,105 @@ + 'Sohbet', + 'headline' => 'Gönderilerinle nasıl yardımcı olabilirim?', + 'description' => 'Son gönderileri veya performanslarını sorun — Claude veya ChatGPT olmadan TryPost MCP ile konuşur gibi.', + 'placeholder' => 'Gönderiler veya metrikler hakkında sorun', + 'send' => 'Gönder', + 'suggestions_label' => 'Şunları sorabilirsiniz', + 'suggestions' => [ + 'posts' => 'Son gönderiler', + 'metrics' => 'Gönderi metrikleri', + ], + 'thinking' => 'Düşünüyor…', + 'groups' => [ + 'today' => 'Bugün', + 'yesterday' => 'Dün', + 'last_7_days' => 'Son 7 gün', + 'last_30_days' => 'Son 30 gün', + 'older' => 'Daha eski', + ], + 'tools' => [ + 'post_not_found' => 'Gönderi bulunamadı.', + 'error' => 'Bir şeyler ters gitti. Lütfen tekrar deneyin.', + 'publish_no_enabled_platforms' => 'Bu gönderide etkin platform yok. Yayınlamadan önce en az birini etkinleştirin.', + 'delete_blocked' => 'Bu gönderi zaten yayınlandı ve TryPost’tan silinemez. Otomatik kaldırmayı desteklemeyen bir platformda yayındaysa, örneğin TikTok, orada elle silmeniz gerekir.', + 'forbidden' => 'Bu çalışma alanında gönderi oluşturma veya değiştirme izniniz yok. İhtiyacınız varsa bir çalışma alanı yöneticisinden isteyin.', + ], + 'approvals' => [ + 'publish' => 'Bu gönderi şimdi yayınlansın mı? Etkin olan her platformda hemen yayına girer ve TikTok gibi bazı platformlarda daha sonra yayından kaldırmanın bir yolu yoktur.', + 'delete_scheduled' => 'Bu gönderiyi silmek geri alınamaz. Yayınlanmak üzere zamanlanmışsa, bu işlem onu da iptal eder. Yine de silinsin mi?', + ], + 'errors' => [ + 'turn_in_progress' => 'Bu sohbet hâlâ yanıt veriyor. Yeni bir mesaj göndermeden önce bitmesini bekleyin.', + 'request_failed' => 'Mesajınız gönderilirken bir şeyler ters gitti. Lütfen tekrar deneyin.', + 'stream_failed' => 'Yanıt verilirken bir şeyler ters gitti. Lütfen tekrar deneyin.', + 'payment_required_cta' => 'Faturalandırmayı yönet', + 'access_error' => 'Bu sohbet kullanılamıyor. Silinmiş olabilir veya başka birine ait olabilir.', + ], + 'tool_names' => [ + 'list_posts' => 'Gönderileri listele', + 'get_post' => 'Gönderi ayrıntıları', + 'get_post_metrics' => 'Gönderi metrikleri', + 'create_post' => 'Gönderi oluştur', + 'update_post' => 'Gönderiyi güncelle', + 'schedule_post' => 'Gönderiyi zamanla', + 'publish_post' => 'Gönderiyi yayınla', + 'delete_post' => 'Gönderiyi sil', + 'start_post_generation' => 'Gönderi seçenekleri', + 'generate_post' => 'Gönderi oluştur', + ], + 'tool_card' => [ + 'running' => 'Çalışıyor…', + 'denied' => 'Bu işlemi reddettiniz.', + 'unknown_tool' => 'Asistan, bu arayüzde henüz kartı olmayan bir araç kullandı.', + 'unreadable_result' => 'Bu sonuç okunamadı.', + 'empty_list' => 'Gönderi bulunamadı.', + 'open_in_editor' => 'Düzenleyicide aç', + 'untitled' => 'Henüz içerik yok.', + 'post_deleted' => 'Bu gönderi silindi.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Henüz yayınlanmadı', + 'platform_not_supported' => 'Bu platform için metrikler kullanılamıyor', + ], + 'empty' => 'Henüz gösterilecek metrik yok.', + ], + 'approval' => [ + 'approve' => 'Onayla', + 'reject' => 'Reddet', + 'generic_reason' => 'Bu işlem gerçekleşmeden önce onayınız gerekiyor.', + ], + 'post_generation' => [ + 'unavailable' => 'Bu çalışma alanında henüz bağlı bir hesap yok, bu yüzden gönderi üretilecek bir format da yok. Önce bir hesap bağla.', + 'styles_unavailable' => 'Şu anda kullanılabilir bir gönderi stili yok, bu yüzden bu formatı oluşturacak bir şey de yok.', + 'format_question' => 'Hangi formatı oluşturayım?', + 'style_question' => 'Hangi stili kullanayım?', + 'images_question' => 'Kaç görsel olsun?', + 'images_none' => 'Yok', + 'account_question' => 'Bu hangi hesap için?', + 'topic_line' => 'Konu: :topic', + 'posting_to' => ':account hesabında yayımlanacak', + 'brand_colors_label' => 'Marka renkleri', + 'brand_colors_description' => 'Çalışma alanının marka paletini üretilen görsellere uygula.', + 'change' => 'Değiştir', + 'submit' => 'Gönderi oluştur', + 'sent' => 'Seçimler gönderildi.', + 'sentence' => ':topic hakkında :format oluştur, :style stili, :images, :account hesabında.', + 'sentence_with_brand' => ':topic hakkında :format oluştur, :style stili, :images, :account hesabında, :brand.', + 'sentence_images_none' => 'görsel yok', + 'sentence_images_one' => '1 görsel', + 'sentence_images_other' => ':count görsel', + 'sentence_brand_on' => 'marka renkleriyle', + 'sentence_brand_off' => 'marka renkleri olmadan', + 'result_waiting' => 'Gönderin oluşturuluyor…', + 'result_elapsed_label' => 'Geçen süre: :elapsed', + 'result_waiting_hint' => 'Sayfadan ayrılsan da devam eder. Gönderi hazır olduğunda burada görünür.', + 'result_detached_hint' => 'Bu arka planda çalışmaya devam ediyor. Gönderiyi görmek için bu sohbeti yeniden aç.', + 'result_ready' => 'Gönderin hazır.', + 'result_failed' => 'Gönderi oluşturulamadı. Tekrar denemek için yeniden iste.', + ], +]; diff --git a/lang/tr/onboarding.php b/lang/tr/onboarding.php index 37906266f..f5ac7a521 100644 --- a/lang/tr/onboarding.php +++ b/lang/tr/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '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ı', + 'title' => 'Sizi en iyi ne tanımlar?', + 'description' => 'En yakın seçeneği seçin, deneyiminizi kişiselleştirelim.', + 'continue' => 'Devam et', + 'subscription_required_title' => 'Hesap sahibi bekleniyor', + 'subscription_required_description' => 'Bu hesabın henüz etkin bir aboneliği yok. Hesap sahibinden ödemeyi tamamlamasını isteyin — abonelik etkinleşir etkinleşmez tam erişiminiz olur.', + 'subscription_required_owner' => 'Hesap sahibiniz :name.', + 'subscription_required_auto' => 'Bu sayfa otomatik olarak güncellenir — yenilemenize gerek yok.', + 'progress' => 'Onboarding ilerlemesi', + 'go_to_step' => ':step. adıma git', + 'step_current' => 'Adım :step (şu anki)', + 'personas' => [ + 'creator' => 'İçerik üreticisi', + 'freelancer' => 'Serbest çalışan', + 'developer' => 'Geliştirici', + 'startup' => 'Girişim', + 'agency' => 'Ajans', + 'small_business' => 'Küçük işletme', + 'marketer' => 'Pazarlamacı', + 'online_store' => 'Çevrimiçi mağaza', + 'other' => 'Diğer', ], - '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ı.', + 'goals_title' => 'Hedefiniz nedir?', + 'goals_description' => 'En uygun olanı seçin, TryPost\'u sizin için ayarlayalım.', + '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', + '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', + 'drive_sales' => 'Daha fazla trafik ve satış elde etmek', + 'manage_clients' => 'Birden fazla marka veya müşteri yönetmek', + 'just_exploring' => 'Şimdilik sadece keşfetmek', + 'other' => 'Başka bir şey', ], - '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.', + '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' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Dizin (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Arkadaş veya meslektaş', + 'founder' => 'Kurucu', + 'blog' => 'Blog / newsletter', + 'other' => 'Başka bir şey', ], - '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', + 'connect' => [ + 'title' => 'En çok hangi sosyal ağı kullanıyorsun?', + 'description' => 'En çok paylaşım yaptığın ağı seç.', + 'follow_up' => ':network hesabını bağla.', + 'latest_post' => 'İşte son paylaşımın.', + 'pitch_views' => '{0} Bu gönderi :network üzerinde :views görüntüleme aldı.|{1} Bu gönderi :network üzerinde :views görüntüleme aldı.|[2,*] Bu gönderi :network üzerinde :views görüntüleme aldı.', + 'pitch_no_views' => 'Bu gönderi yalnızca :network’te yayınlandı.', + 'pitch_missed' => '{0}|{1} Aynı gönderi :first’da rahatça :each görüntüleme daha alabilir. Bunu hiç görmeyen :extra kişi var.|[2,*] Aynı gönderi :first ve :second’da her birinde rahatça :each görüntüleme daha alabilir. Bunu hiç görmeyen :extra kişi var.', + 'pitch_sales' => 'Binlerce satışı kaçırıyor olabilirsin.', + 'change_network' => 'Ağı değiştir', + 'required' => 'Devam etmek için en az bir sosyal hesap bağla.', ], - 'ready' => [ - 'title' => 'Yayınlamaya hazırsın', - 'description' => 'Her şey hazır. TryPost’a devam et ve içeriğini planlamaya başla.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/tr/posts.php b/lang/tr/posts.php index f19d6c8a1..e554effe8 100644 --- a/lang/tr/posts.php +++ b/lang/tr/posts.php @@ -593,80 +593,18 @@ 'cancel' => 'İptal', ], - 'create' => [ - 'title' => 'Yeni bir gönderi oluştur', - 'description' => 'Nasıl başlamak istediğinizi seçin.', - 'scratch_title' => 'Sıfırdan başla', - 'scratch_description' => 'Boş bir gönderi açın ve her şeyi kendiniz yazın.', - 'ai_title' => 'AI ile oluştur', - 'ai_description' => 'İstediğinizi açıklayın, AI içeriği sizin için oluştursun.', - 'ai_configure_description' => 'Bir format seçin ve oluşturmak istediğiniz gönderiyi açıklayın.', - 'ai_pick_template_description' => 'AI ile oluşturulan gönderiniz için bir stil seçin.', - 'coming_soon' => 'Yakında', - - 'preview' => [ - 'image_title' => 'Görsel başlığı', - 'image_body' => 'Görsel gövdesi', - ], - - 'steps' => [ - 'template_picker_title' => 'Bir stil seçin', - 'format_title' => 'Bir format seçin', - 'format_description' => 'Oluşturmak istediğiniz gönderi türünü seçin.', - 'account_title' => 'Bir hesap seçin', - 'account_description' => 'Yayınlanacak sosyal hesabı seçin.', - 'media_title' => 'Medya seçenekleri', - 'media_carousel' => 'Kaç slayt?', - 'media_optional' => 'Görsel eklensin mi?', - 'media_optional_label' => 'Kaç görsel?', - 'media_none' => 'Hiçbiri', - 'media_count_label' => 'Görsel sayısı', - 'brand_colors_label' => 'Marka renkleri', - 'brand_colors_description' => 'Oluşturulan görsellere çalışma alanınızın marka paletini uygular. Yapay zekânın içeriğe uygun renkleri seçmesi için kapatın.', - 'prompt_title' => 'Gönderinizi açıklayın', - 'prompt_label' => 'Bu gönderi ne hakkında?', - 'prompt_placeholder' => 'örn. Instagram için yeni karusel özelliğimizi duyur', - 'preview_error' => 'Bir şeyler ters gitti. Lütfen tekrar deneyin.', - 'loading_page_title' => 'Gönderiniz oluşturuluyor', - 'loading_eta' => 'Tahmini süre: yaklaşık :minutes.', - 'loading_eta_minute_one' => '1 dakika', - 'loading_eta_minute_other' => ':count dakika', - 'loading_leave_title' => 'Çalışmaya devam edebilirsiniz.', - 'loading_leave_body' => 'Gönderi hazır olduğunda sizi bilgilendireceğiz.', - 'loading_leave_cta' => 'Takvime git', - 'loading_create_another_cta' => 'Başka bir gönderi oluştur', - 'loading_tip_credits' => 'Her AI görseli yaklaşık 15 kredi kullanır.', - 'loading_tip_edit' => 'Gönderi hazır olduğunda her şeyi düzenleyebileceksiniz.', - 'loading_tip_draft' => 'Oluşturulan gönderiler taslaklarınıza gider.', - 'loading_tip_brand' => 'Gelecekteki gönderileri etkilemek için marka ayarlarınızı düzenleyin.', - 'loading_tip_carousel' => 'Karuseller yüklenen her görsel için bir slayt sunar.', - 'loading_tip_quality' => 'Görsel kalitesi, hız ve maliyeti dengelemek için ayarlanır.', - 'create' => 'Gönderi oluştur', - 'back' => 'Geri', - 'next' => 'Devam et', - 'cancel' => 'İptal', - 'discard' => 'Vazgeç', - 'retry' => 'Tekrar dene', - 'no_platforms' => 'Bağlı hesap yok', - 'connect_first' => 'AI oluşturmayı kullanmak için en az bir sosyal hesap bağlayın.', - 'no_account_for_template' => 'Bu şablonu kullanmak için uyumlu bir hesap bağlayın.', - - 'format' => [ - 'instagram_feed' => 'Instagram Akış Gönderisi', - 'instagram_carousel' => 'Instagram Karuseli', - 'linkedin_post' => 'LinkedIn Gönderisi', - 'linkedin_page_post' => 'LinkedIn Sayfa Gönderisi', - 'x_post' => 'X Gönderisi', - 'bluesky_post' => 'Bluesky Gönderisi', - 'threads_post' => 'Threads Gönderisi', - 'mastodon_post' => 'Mastodon Gönderisi', - 'telegram_post' => 'Telegram Gönderisi', - 'discord_message' => 'Discord Mesajı', - 'facebook_post' => 'Facebook Gönderisi', - 'pinterest_pin' => 'Pinterest Pin\'i', - 'instagram_story' => 'Instagram Hikayesi', - 'facebook_story' => 'Facebook Hikayesi', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram Akış Gönderisi', + 'instagram_carousel' => 'Instagram Karuseli', + 'linkedin_post' => 'LinkedIn Gönderisi', + 'linkedin_page_post' => 'LinkedIn Sayfa Gönderisi', + 'x_post' => 'X Gönderisi', + 'bluesky_post' => 'Bluesky Gönderisi', + 'threads_post' => 'Threads Gönderisi', + 'mastodon_post' => 'Mastodon Gönderisi', + 'facebook_post' => 'Facebook Gönderisi', + 'pinterest_pin' => 'Pinterest Pin\'i', + 'instagram_story' => 'Instagram Hikayesi', ], ]; diff --git a/lang/tr/sidebar.php b/lang/tr/sidebar.php index 64a8532c1..f91eb4373 100644 --- a/lang/tr/sidebar.php +++ b/lang/tr/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Analitik', 'automations' => 'Otomasyonlar', - 'onboarding' => 'Başlarken', - 'onboarding_hint' => 'Kurulumu bitir', + 'browse' => 'Menü', + 'chat' => 'Sohbet', + 'chat_history' => 'Geçmiş', + 'new_chat' => 'Yeni sohbet', + 'no_chats' => 'Henüz sohbet yok', + 'last_7_days' => 'Son 7 gün', + 'last_30_days' => 'Son 30 gün', + 'older' => 'Daha eski', 'posts' => [ 'calendar' => 'Takvim', 'all' => 'Tümü', diff --git a/lang/tr/welcome.php b/lang/tr/welcome.php deleted file mode 100644 index 9d603e60b..000000000 --- a/lang/tr/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Sizi en iyi ne tanımlar?', - 'description' => 'En yakın seçeneği seçin, deneyiminizi kişiselleştirelim.', - 'continue' => 'Devam et', - 'subscription_required_title' => 'Hesap sahibi bekleniyor', - 'subscription_required_description' => 'Bu hesabın henüz etkin bir aboneliği yok. Hesap sahibinden ödemeyi tamamlamasını isteyin — abonelik etkinleşir etkinleşmez tam erişiminiz olur.', - '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)', - 'personas' => [ - 'creator' => 'İçerik üreticisi', - 'freelancer' => 'Serbest çalışan', - 'developer' => 'Geliştirici', - 'startup' => 'Girişim', - 'agency' => 'Ajans', - 'small_business' => 'Küçük işletme', - 'marketer' => 'Pazarlamacı', - 'online_store' => 'Çevrimiçi mağaza', - 'other' => 'Diğer', - ], - 'goals_title' => 'Hedefiniz nedir?', - 'goals_description' => 'Size uyan her şeyi seçin, biz de TryPost\'u sizin için ayarlayalım.', - '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', - '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', - 'drive_sales' => 'Daha fazla trafik ve satış elde etmek', - 'manage_clients' => 'Birden fazla marka veya müşteri yönetmek', - 'just_exploring' => 'Şimdilik sadece keşfetmek', - 'other' => 'Başka bir şey', - ], - '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' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Dizin (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Arkadaş veya meslektaş', - 'founder' => 'Kurucu', - 'blog' => 'Blog / newsletter', - 'other' => 'Başka bir şey', - ], - 'connect' => [ - 'title' => 'Bir sosyal hesap bağla', - 'description' => 'TryPost’un içeriğini yayınlayabileceği en az bir ağ seç.', - 'required' => 'Devam etmek için en az bir sosyal hesap bağla.', - ], -]; diff --git a/lang/uk/chat.php b/lang/uk/chat.php new file mode 100644 index 000000000..62fc6d83a --- /dev/null +++ b/lang/uk/chat.php @@ -0,0 +1,105 @@ + 'Чат', + 'headline' => 'Чим допомогти з вашими постами?', + 'description' => 'Запитайте про нещодавні пости або їхні результати — ніби розмова з MCP TryPost, без Claude чи ChatGPT.', + 'placeholder' => 'Запитайте про пости або метрики', + 'send' => 'Надіслати', + 'suggestions_label' => 'Спробуйте запитати про', + 'suggestions' => [ + 'posts' => 'Нещодавні пости', + 'metrics' => 'Метрики постів', + ], + 'thinking' => 'Думаю…', + 'groups' => [ + 'today' => 'Сьогодні', + 'yesterday' => 'Вчора', + 'last_7_days' => 'Останні 7 днів', + 'last_30_days' => 'Останні 30 днів', + 'older' => 'Раніше', + ], + 'tools' => [ + 'post_not_found' => 'Пост не знайдено.', + 'error' => 'Щось пішло не так. Спробуйте ще раз.', + 'publish_no_enabled_platforms' => 'У цього поста немає жодної увімкненої платформи. Увімкніть хоча б одну перед публікацією.', + 'delete_blocked' => 'Цей пост уже опубліковано, і його не можна видалити з TryPost. Якщо він активний на платформі, яка не підтримує автоматичне видалення, наприклад TikTok, видаліть його там вручну.', + 'forbidden' => 'У вас немає прав створювати чи змінювати пости в цьому робочому просторі. Попросіть їх в адміністратора робочого простору, якщо вони потрібні.', + ], + 'approvals' => [ + 'publish' => 'Опублікувати цей пост зараз? Він одразу з’явиться на кожній увімкненій платформі, а деякі платформи — наприклад TikTok — не дають змоги скасувати публікацію згодом.', + 'delete_scheduled' => 'Видалення цього поста не можна скасувати. Якщо його заплановано до публікації, це також скасує публікацію. Видалити попри це?', + ], + 'errors' => [ + 'turn_in_progress' => 'Ця розмова ще відповідає. Зачекайте на завершення, перш ніж надсилати наступне повідомлення.', + 'request_failed' => 'Не вдалося надіслати повідомлення. Спробуйте ще раз.', + 'stream_failed' => 'Під час відповіді щось пішло не так. Спробуйте ще раз.', + 'payment_required_cta' => 'Керувати оплатою', + 'access_error' => 'Ця розмова недоступна. Можливо, її видалено або вона належить іншому користувачу.', + ], + 'tool_names' => [ + 'list_posts' => 'Список постів', + 'get_post' => 'Деталі поста', + 'get_post_metrics' => 'Метрики поста', + 'create_post' => 'Створити пост', + 'update_post' => 'Оновити пост', + 'schedule_post' => 'Запланувати пост', + 'publish_post' => 'Опублікувати пост', + 'delete_post' => 'Видалити пост', + 'start_post_generation' => 'Параметри допису', + 'generate_post' => 'Згенерувати допис', + ], + 'tool_card' => [ + 'running' => 'Виконується…', + 'denied' => 'Ви відхилили цю дію.', + 'unknown_tool' => 'Асистент використав інструмент, для якого цей інтерфейс ще не має картки.', + 'unreadable_result' => 'Не вдалося прочитати цей результат.', + 'empty_list' => 'Постів не знайдено.', + 'open_in_editor' => 'Відкрити в редакторі', + 'untitled' => 'Поки що немає вмісту.', + 'post_deleted' => 'Цей пост видалено.', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => 'Ще не опубліковано', + 'platform_not_supported' => 'Метрики недоступні для цієї платформи', + ], + 'empty' => 'Поки що немає метрик для показу.', + ], + 'approval' => [ + 'approve' => 'Схвалити', + 'reject' => 'Відхилити', + 'generic_reason' => 'Ця дія потребує вашого схвалення перед виконанням.', + ], + 'post_generation' => [ + 'unavailable' => 'У цьому робочому просторі ще немає підключених акаунтів, тож немає формату, для якого можна згенерувати допис. Спершу підключіть акаунт.', + 'styles_unavailable' => 'Зараз немає жодного стилю допису, тож цей формат немає чим згенерувати.', + 'format_question' => 'Який формат створити?', + 'style_question' => 'Який стиль використати?', + 'images_question' => 'Скільки зображень потрібно?', + 'images_none' => 'Без зображень', + 'account_question' => 'Для якого облікового запису це?', + 'topic_line' => 'Тема: :topic', + 'posting_to' => 'Публікація в :account', + 'brand_colors_label' => 'Кольори бренду', + 'brand_colors_description' => 'Застосувати палітру бренду робочого простору до згенерованих зображень.', + 'change' => 'Змінити', + 'submit' => 'Згенерувати допис', + 'sent' => 'Вибір надіслано.', + 'sentence' => 'Згенеруй :format про :topic, стиль :style, :images, для :account.', + 'sentence_with_brand' => 'Згенеруй :format про :topic, стиль :style, :images, для :account, :brand.', + 'sentence_images_none' => 'без зображень', + 'sentence_images_one' => '1 зображення', + 'sentence_images_other' => ':count зображень', + 'sentence_brand_on' => 'з кольорами бренду', + 'sentence_brand_off' => 'без кольорів бренду', + 'result_waiting' => 'Генерую ваш допис…', + 'result_elapsed_label' => 'Минуло: :elapsed', + 'result_waiting_hint' => 'Це триває, навіть якщо ви залишите сторінку. Допис з’явиться тут, щойно буде готовий.', + 'result_detached_hint' => 'Це триває у фоновому режимі. Відкрийте цю розмову знову, щоб побачити допис.', + 'result_ready' => 'Ваш допис готовий.', + 'result_failed' => 'Не вдалося згенерувати допис. Попросіть ще раз, щоб спробувати знову.', + ], +]; diff --git a/lang/uk/onboarding.php b/lang/uk/onboarding.php index abc5e1b2e..c993067a6 100644 --- a/lang/uk/onboarding.php +++ b/lang/uk/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => 'Початок роботи', - 'welcome' => 'Ласкаво просимо до TryPost, :name', - 'description' => 'Виконайте кроки нижче, щоб зрозуміти, як працює TryPost, і опублікувати свій перший пост.', - 'skip_step' => 'Пропустити цей крок', - 'continue' => 'Продовжити до TryPost', - 'status' => [ - 'complete' => 'Готово', - 'todo' => 'До виконання', - 'skipped' => 'Пропущено', + 'title' => 'Що найкраще вас описує?', + 'description' => 'Оберіть найближчий варіант — ми адаптуємо ваш досвід.', + 'continue' => 'Продовжити', + 'subscription_required_title' => 'Очікуємо власника акаунта', + 'subscription_required_description' => 'У цього акаунта ще немає активної підписки. Попросіть власника завершити оплату — повний доступ з’явиться одразу після активації.', + 'subscription_required_owner' => 'Власник вашого акаунта — :name.', + 'subscription_required_auto' => 'Ця сторінка оновлюється автоматично — оновлювати вручну не потрібно.', + 'progress' => 'Прогрес онбордингу', + 'go_to_step' => 'Перейти до кроку :step', + 'step_current' => 'Крок :step (поточний)', + 'personas' => [ + 'creator' => 'Автор контенту', + 'freelancer' => 'Фрілансер', + 'developer' => 'Розробник', + 'startup' => 'Стартап', + 'agency' => 'Агенція', + 'small_business' => 'Малий бізнес', + 'marketer' => 'Маркетолог', + 'online_store' => 'Інтернет-магазин', + 'other' => 'Інше', ], - 'mcp' => [ - 'title' => 'Підключіть свого AI-асистента', - 'description' => 'Додайте TryPost як MCP-сервер, щоб асистент міг створювати й керувати соцмережевими постами за вас.', - 'copied' => 'MCP URL скопійовано.', + 'goals_title' => 'Яка ваша мета?', + 'goals_description' => 'Оберіть те, що пасує найбільше, і ми налаштуємо TryPost для вас.', + 'goals' => [ + 'save_time' => 'Економити час, публікуючи всюди одразу', + 'ai_content' => 'Генерувати пости з AI TryPost', + 'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor', + 'plan_calendar' => 'Планувати пости в календарі', + 'stay_on_brand' => 'Тримати кожен пост у стилі бренду', + 'grow_audience' => 'Збільшувати аудиторію та залучення', + 'drive_sales' => 'Отримувати більше трафіку та продажів', + 'manage_clients' => 'Керувати кількома брендами або клієнтами', + 'just_exploring' => 'Поки що просто досліджую', + 'other' => 'Щось інше', ], - 'social' => [ - 'title' => 'Підключіть соціальний акаунт', - 'description' => 'Оберіть принаймні одну мережу, у якій TryPost зможе публікувати ваш контент.', - 'connected_elsewhere' => 'Ви вже підключили акаунт в іншому робочому просторі, тож цей крок виконано.', + 'referral_source_title' => 'Як ви нас знайшли?', + 'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => 'Каталог (G2, Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => 'Друг або колега', + 'founder' => 'Засновник', + 'blog' => 'Blog / newsletter', + 'other' => 'Щось інше', ], - 'first_post' => [ - 'title' => 'Створіть свій перший пост', - 'description' => 'Спробуйте цей стартовий промпт із підключеним асистентом або створіть пост безпосередньо в TryPost.', - 'prompt_label' => 'Приклад промпту', - 'sample_prompt' => 'Створи дружній пост, що представляє мій бренд, і адаптуй його для кожної підключеної мережі.', - 'copy_prompt' => 'Скопіювати промпт', - 'copied' => 'Приклад промпту скопійовано.', - 'create_button' => 'Створити перший пост', - 'or' => 'або', + 'connect' => [ + 'title' => 'Якою соцмережею ви користуєтесь найчастіше?', + 'description' => 'Оберіть ту, де ви публікуєте найбільше.', + 'follow_up' => 'Підключіть акаунт :network.', + 'latest_post' => 'Ось ваш останній допис.', + 'pitch_views' => '{0} Цей допис має :views переглядів у :network.|{1} Цей допис має :views перегляд у :network.|[2,*] Цей допис має :views переглядів у :network.', + 'pitch_no_views' => 'Цей допис вийшов лише в :network.', + 'pitch_missed' => '{0}|{1} Той самий допис у :first легко набере ще :each переглядів. Це :extra людей, які його не бачили.|[2,*] Той самий допис у :first і :second легко набере ще по :each переглядів. Це :extra людей, які його не бачили.', + 'pitch_sales' => 'Ви можете втрачати тисячі продажів.', + 'change_network' => 'Змінити мережу', + 'required' => 'Підключіть принаймні одну соцмережу, щоб продовжити.', ], - 'ready' => [ - 'title' => 'Ви готові публікувати', - 'description' => 'Усе налаштовано. Продовжуйте в TryPost і починайте планувати контент.', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/uk/posts.php b/lang/uk/posts.php index bcd9b2505..995ff2628 100644 --- a/lang/uk/posts.php +++ b/lang/uk/posts.php @@ -591,80 +591,18 @@ 'cancel' => 'Скасувати', ], - 'create' => [ - 'title' => 'Створити новий пост', - 'description' => 'Виберіть, з чого хочете почати.', - 'scratch_title' => 'Почати з нуля', - 'scratch_description' => 'Відкрийте порожній пост і напишіть усе самі.', - 'ai_title' => 'Згенерувати за допомогою AI', - 'ai_description' => 'Опишіть, що хочете, і AI згенерує вміст за вас.', - 'ai_configure_description' => 'Виберіть формат і опишіть пост, який хочете створити.', - 'ai_pick_template_description' => 'Виберіть стиль для вашого AI-поста.', - 'coming_soon' => 'Незабаром', - - 'preview' => [ - 'image_title' => 'Заголовок зображення', - 'image_body' => 'Текст зображення', - ], - - 'steps' => [ - 'template_picker_title' => 'Виберіть стиль', - 'format_title' => 'Виберіть формат', - 'format_description' => 'Виберіть тип поста, який хочете створити.', - 'account_title' => 'Виберіть акаунт', - 'account_description' => 'Виберіть соціальний акаунт для публікації.', - 'media_title' => 'Параметри медіа', - 'media_carousel' => 'Скільки слайдів?', - 'media_optional' => 'Додати зображення?', - 'media_optional_label' => 'Скільки зображень?', - 'media_none' => 'Немає', - 'media_count_label' => 'Кількість зображень', - 'brand_colors_label' => 'Кольори бренду', - 'brand_colors_description' => 'Застосовуйте фірмову палітру вашого workspace до згенерованих зображень. Вимкніть, щоб AI підібрав кольори під вміст.', - 'prompt_title' => 'Опишіть свій пост', - 'prompt_label' => 'Про що цей пост?', - 'prompt_placeholder' => 'наприклад, Оголосити нашу нову функцію каруселі для Instagram', - 'preview_error' => 'Щось пішло не так. Спробуйте ще раз.', - 'loading_page_title' => 'Генеруємо ваш пост', - 'loading_eta' => 'Орієнтовний час: близько :minutes.', - 'loading_eta_minute_one' => '1 хвилина', - 'loading_eta_minute_other' => ':count хвилин', - 'loading_leave_title' => 'Ви можете продовжувати працювати.', - 'loading_leave_body' => 'Ми повідомимо вас, коли пост буде готовий.', - 'loading_leave_cta' => 'Перейти до календаря', - 'loading_create_another_cta' => 'Створити ще один пост', - 'loading_tip_credits' => 'Кожне AI-зображення використовує близько 15 кредитів.', - 'loading_tip_edit' => 'Ви зможете відредагувати все, коли пост буде готовий.', - 'loading_tip_draft' => 'Згенеровані пости потрапляють у чернетки.', - 'loading_tip_brand' => 'Налаштуйте параметри бренду, щоб впливати на майбутні пости.', - 'loading_tip_carousel' => 'У каруселі створюється один слайд на кожне завантажене зображення.', - 'loading_tip_quality' => 'Якість зображень налаштована на баланс швидкості та вартості.', - 'create' => 'Створити пост', - 'back' => 'Назад', - 'next' => 'Продовжити', - 'cancel' => 'Скасувати', - 'discard' => 'Скасувати', - 'retry' => 'Спробувати знову', - 'no_platforms' => 'Немає підключених акаунтів', - 'connect_first' => 'Підключіть принаймні один соціальний акаунт, щоб використовувати генерацію AI.', - 'no_account_for_template' => 'Підключіть сумісний акаунт, щоб використати цей шаблон.', - - 'format' => [ - 'instagram_feed' => 'Пост у стрічці Instagram', - 'instagram_carousel' => 'Карусель Instagram', - 'linkedin_post' => 'Пост LinkedIn', - 'linkedin_page_post' => 'Пост сторінки LinkedIn', - 'x_post' => 'Пост X', - 'bluesky_post' => 'Пост Bluesky', - 'threads_post' => 'Пост Threads', - 'mastodon_post' => 'Пост Mastodon', - 'telegram_post' => 'Пост Telegram', - 'discord_message' => 'Повідомлення Discord', - 'facebook_post' => 'Пост Facebook', - 'pinterest_pin' => 'Pin Pinterest', - 'instagram_story' => 'Stories Instagram', - 'facebook_story' => 'Stories Facebook', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Пост у стрічці Instagram', + 'instagram_carousel' => 'Карусель Instagram', + 'linkedin_post' => 'Пост LinkedIn', + 'linkedin_page_post' => 'Пост сторінки LinkedIn', + 'x_post' => 'Пост X', + 'bluesky_post' => 'Пост Bluesky', + 'threads_post' => 'Пост Threads', + 'mastodon_post' => 'Пост Mastodon', + 'facebook_post' => 'Пост Facebook', + 'pinterest_pin' => 'Pin Pinterest', + 'instagram_story' => 'Stories Instagram', ], ]; diff --git a/lang/uk/sidebar.php b/lang/uk/sidebar.php index 399a8962a..c25fe0ae1 100644 --- a/lang/uk/sidebar.php +++ b/lang/uk/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => 'Аналітика', 'automations' => 'Автоматизації', - 'onboarding' => 'Початок роботи', - 'onboarding_hint' => 'Завершіть налаштування', + 'browse' => 'Меню', + 'chat' => 'Чат', + 'chat_history' => 'Історія', + 'new_chat' => 'Новий чат', + 'no_chats' => 'Ще немає чатів', + 'last_7_days' => 'Останні 7 днів', + 'last_30_days' => 'Останні 30 днів', + 'older' => 'Старіші', 'posts' => [ 'calendar' => 'Календар', 'all' => 'Усі', diff --git a/lang/uk/welcome.php b/lang/uk/welcome.php deleted file mode 100644 index cb96dab64..000000000 --- a/lang/uk/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - 'Що найкраще вас описує?', - 'description' => 'Оберіть найближчий варіант — ми адаптуємо ваш досвід.', - 'continue' => 'Продовжити', - 'subscription_required_title' => 'Очікуємо власника акаунта', - 'subscription_required_description' => 'У цього акаунта ще немає активної підписки. Попросіть власника завершити оплату — повний доступ з’явиться одразу після активації.', - 'subscription_required_owner' => 'Власник вашого акаунта — :name.', - 'subscription_required_auto' => 'Ця сторінка оновлюється автоматично — оновлювати вручну не потрібно.', - 'progress' => 'Прогрес привітання', - 'go_to_step' => 'Перейти до кроку :step', - 'step_current' => 'Крок :step (поточний)', - 'personas' => [ - 'creator' => 'Автор контенту', - 'freelancer' => 'Фрілансер', - 'developer' => 'Розробник', - 'startup' => 'Стартап', - 'agency' => 'Агенція', - 'small_business' => 'Малий бізнес', - 'marketer' => 'Маркетолог', - 'online_store' => 'Інтернет-магазин', - 'other' => 'Інше', - ], - 'goals_title' => 'Яка ваша мета?', - 'goals_description' => 'Виберіть усе, що підходить, і ми налаштуємо TryPost для вас.', - 'goals' => [ - 'save_time' => 'Економити час, публікуючи всюди одразу', - 'ai_content' => 'Генерувати пости з AI TryPost', - 'use_mcp' => 'Створювати пости через Claude, ChatGPT або Cursor', - 'plan_calendar' => 'Планувати пости в календарі', - 'stay_on_brand' => 'Тримати кожен пост у стилі бренду', - 'grow_audience' => 'Збільшувати аудиторію та залучення', - 'drive_sales' => 'Отримувати більше трафіку та продажів', - 'manage_clients' => 'Керувати кількома брендами або клієнтами', - 'just_exploring' => 'Поки що просто досліджую', - 'other' => 'Щось інше', - ], - 'referral_source_title' => 'Як ви нас знайшли?', - 'referral_source_description' => 'Це допомагає нам зрозуміти, як люди дізнаються про TryPost.', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => 'Каталог (G2, Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => 'Друг або колега', - 'founder' => 'Засновник', - 'blog' => 'Blog / newsletter', - 'other' => 'Щось інше', - ], - 'connect' => [ - 'title' => 'Підключіть соцмережу', - 'description' => 'Оберіть принаймні одну мережу, де TryPost зможе публікувати ваш контент.', - 'required' => 'Підключіть принаймні одну соцмережу, щоб продовжити.', - ], -]; diff --git a/lang/zh/chat.php b/lang/zh/chat.php new file mode 100644 index 000000000..b23293a84 --- /dev/null +++ b/lang/zh/chat.php @@ -0,0 +1,105 @@ + '聊天', + 'headline' => '我能怎样帮你处理帖子?', + 'description' => '询问最近的帖子或它们的表现 — 就像直接和 TryPost MCP 对话,不用 Claude 或 ChatGPT。', + 'placeholder' => '询问帖子或数据', + 'send' => '发送', + 'suggestions_label' => '可以问问', + 'suggestions' => [ + 'posts' => '最近的帖子', + 'metrics' => '帖子数据', + ], + 'thinking' => '思考中…', + 'groups' => [ + 'today' => '今天', + 'yesterday' => '昨天', + 'last_7_days' => '过去 7 天', + 'last_30_days' => '过去 30 天', + 'older' => '更早', + ], + 'tools' => [ + 'post_not_found' => '未找到该帖子。', + 'error' => '出了点问题,请重试。', + 'publish_no_enabled_platforms' => '这条帖子没有启用任何平台,发布前请至少启用一个。', + 'delete_blocked' => '这条帖子已经发布,无法从 TryPost 中删除。如果它发布在不支持自动删除的平台上(比如 TikTok),你需要去那边手动删除。', + 'forbidden' => '你没有在此工作区创建或修改帖子的权限。如果需要,请向工作区管理员申请。', + ], + 'approvals' => [ + 'publish' => '现在发布这条帖子吗?它会立即在每个已启用的平台上线,其中有些平台 — 比如 TikTok — 之后没有办法取消发布。', + 'delete_scheduled' => '删除这条帖子后无法恢复。如果它已安排发布,这也会取消该安排。仍要删除吗?', + ], + 'errors' => [ + 'turn_in_progress' => '该对话仍在回复中。请等待回复完成后再发送新消息。', + 'request_failed' => '发送消息时出了点问题,请重试。', + 'stream_failed' => '回复时出了点问题,请重试。', + 'payment_required_cta' => '管理账单', + 'access_error' => '此对话不可用,可能已被删除,或属于其他用户。', + ], + 'tool_names' => [ + 'list_posts' => '列出帖子', + 'get_post' => '帖子详情', + 'get_post_metrics' => '帖子数据', + 'create_post' => '创建帖子', + 'update_post' => '更新帖子', + 'schedule_post' => '安排帖子', + 'publish_post' => '发布帖子', + 'delete_post' => '删除帖子', + 'start_post_generation' => '帖子选项', + 'generate_post' => '生成帖子', + ], + 'tool_card' => [ + 'running' => '执行中…', + 'denied' => '你已拒绝此操作。', + 'unknown_tool' => '助手使用了一个此界面尚无对应卡片的工具。', + 'unreadable_result' => '无法读取此结果。', + 'empty_list' => '未找到帖子。', + 'open_in_editor' => '在编辑器中打开', + 'untitled' => '暂无内容。', + 'post_deleted' => '该帖子已被删除。', + ], + 'metrics' => [ + 'unsupported' => [ + 'not_published' => '尚未发布', + 'platform_not_supported' => '该平台暂不支持数据', + ], + 'empty' => '暂无可显示的数据。', + ], + 'approval' => [ + 'approve' => '批准', + 'reject' => '拒绝', + 'generic_reason' => '此操作需要你批准后才能执行。', + ], + 'post_generation' => [ + 'unavailable' => '这个工作区还没有已连接的账号,因此没有可生成帖子的格式。请先连接一个账号。', + 'styles_unavailable' => '目前没有可用的帖子风格,因此无法生成这个格式。', + 'format_question' => '要生成哪种格式?', + 'style_question' => '要用哪种风格?', + 'images_question' => '需要几张图片?', + 'images_none' => '无', + 'account_question' => '这是给哪个账号的?', + 'topic_line' => '主题::topic', + 'posting_to' => '将发布到 :account', + 'brand_colors_label' => '品牌配色', + 'brand_colors_description' => '为生成的图片套用工作区的品牌配色。', + 'change' => '更改', + 'submit' => '生成帖子', + 'sent' => '已发送选择。', + 'sentence' => '用 :account 生成关于 :topic 的 :format,:style 风格,:images。', + 'sentence_with_brand' => '用 :account 生成关于 :topic 的 :format,:style 风格,:images,:brand。', + 'sentence_images_none' => '不配图', + 'sentence_images_one' => '1 张图片', + 'sentence_images_other' => ':count 张图片', + 'sentence_brand_on' => '使用品牌配色', + 'sentence_brand_off' => '不使用品牌配色', + 'result_waiting' => '正在生成你的帖子…', + 'result_elapsed_label' => '已用时间::elapsed', + 'result_waiting_hint' => '即使你离开页面也会继续运行。帖子准备好后会显示在这里。', + 'result_detached_hint' => '这会在后台继续运行。重新打开这个对话即可看到帖子。', + 'result_ready' => '你的帖子已就绪。', + 'result_failed' => '无法生成帖子。再问一次即可重试。', + ], +]; diff --git a/lang/zh/onboarding.php b/lang/zh/onboarding.php index 228ef1b24..d88b0798d 100644 --- a/lang/zh/onboarding.php +++ b/lang/zh/onboarding.php @@ -3,38 +3,82 @@ declare(strict_types=1); return [ - 'title' => '开始使用', - 'welcome' => '欢迎使用 TryPost,:name', - 'description' => '按下面的步骤了解 TryPost 的用法,并发布你的第一条内容。', - 'skip_step' => '跳过此步骤', - 'continue' => '继续前往 TryPost', - 'status' => [ - 'complete' => '已完成', - 'todo' => '待完成', - 'skipped' => '已跳过', + 'title' => '哪项最能描述你?', + 'description' => '选择最接近的一项,我们会为你定制体验。', + 'continue' => '继续', + 'subscription_required_title' => '等待账户所有者', + 'subscription_required_description' => '此账户还没有有效订阅。请让账户所有者完成结账 — 订阅生效后您即可获得完整访问权限。', + 'subscription_required_owner' => '您的账户所有者是 :name。', + 'subscription_required_auto' => '此页面会自动更新 — 无需刷新。', + 'progress' => '新手引导进度', + 'go_to_step' => '前往第 :step 步', + 'step_current' => '第 :step 步(当前)', + 'personas' => [ + 'creator' => '内容创作者', + 'freelancer' => '自由职业者', + 'developer' => '开发者', + 'startup' => '初创公司', + 'agency' => '代理机构', + 'small_business' => '小型企业', + 'marketer' => '营销人员', + 'online_store' => '网店', + 'other' => '其他', ], - 'mcp' => [ - 'title' => '连接你的 AI 助手', - 'description' => '将 TryPost 添加为 MCP 服务器,让助手帮你创建和管理社交内容。', - 'copied' => '已复制 MCP URL。', + 'goals_title' => '你的目标是什么?', + 'goals_description' => '选最符合的一项,我们会为你配置好 TryPost。', + 'goals' => [ + 'save_time' => '一次发布到所有平台,节省时间', + 'ai_content' => '用 TryPost AI 生成帖子', + 'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子', + 'plan_calendar' => '在日历上规划我的帖子', + 'stay_on_brand' => '让每一条帖子都符合品牌调性', + 'grow_audience' => '增长我的受众和互动', + 'drive_sales' => '获得更多流量和销量', + 'manage_clients' => '管理多个品牌或客户', + 'just_exploring' => '目前只是随便看看', + 'other' => '其他需求', ], - 'social' => [ - 'title' => '连接社交账号', - 'description' => '至少选择一个网络,让 TryPost 可以发布你的内容。', - 'connected_elsewhere' => '你已在其他工作空间连接了账户,因此这一步已完成。', + 'referral_source_title' => '您是如何找到我们的?', + 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', + 'referral_source' => [ + 'google' => 'Google', + 'x' => 'X (Twitter)', + 'linkedin' => 'LinkedIn', + 'youtube' => 'YouTube', + 'tiktok' => 'TikTok', + 'instagram' => 'Instagram', + 'threads' => 'Threads', + 'reddit' => 'Reddit', + 'product_hunt' => 'Product Hunt', + 'github' => 'GitHub', + 'hacker_news' => 'Hacker News', + 'directories' => '软件目录(G2、Capterra…)', + 'ai_assistant' => 'ChatGPT / Claude', + 'friend' => '朋友或同事', + 'founder' => '创始人', + 'blog' => 'Blog / newsletter', + 'other' => '其他', ], - 'first_post' => [ - 'title' => '创建第一条内容', - 'description' => '用已连接的助手试试这个入门提示词,或直接在 TryPost 中创建内容。', - 'prompt_label' => '示例提示词', - 'sample_prompt' => '写一条友好的社交内容介绍我的品牌,并为每个已连接的网络做适配。', - 'copy_prompt' => '复制提示词', - 'copied' => '已复制示例提示词。', - 'create_button' => '创建第一条内容', - 'or' => '或', + 'connect' => [ + 'title' => '你最常用哪个社交平台?', + 'description' => '选你发得最多的那个。', + 'follow_up' => '连接你的 :network 账号。', + 'latest_post' => '这是你最近的一条帖子。', + 'pitch_views' => '{0} 这条帖子在:network上有:views次观看。|{1} 这条帖子在:network上有:views次观看。|[2,*] 这条帖子在:network上有:views次观看。', + 'pitch_no_views' => '这条帖子只发在了:network。', + 'pitch_missed' => '{0}|{1} 同一条发到:first,再多:each次观看很常见。等于有:extra人没看到。|[2,*] 同一条发到:first和:second,每个平台再多:each次观看很常见。等于有:extra人没看到。', + 'pitch_sales' => '你可能正在丢掉成千上万的成交。', + 'change_network' => '更换平台', + 'required' => '请至少连接一个社交账号后再继续。', ], - 'ready' => [ - 'title' => '可以开始发布了', - 'description' => '一切就绪。继续前往 TryPost,开始规划你的内容。', + 'publish_method' => [ + 'title' => 'Will you schedule your posts manually or with AI?', + 'description' => 'Manually, you write and schedule in TryPost. With AI, Claude, ChatGPT, or Cursor publish for you.', + 'manual' => 'Manually', + 'ai' => 'With AI', + 'mcp' => 'Connect Claude, ChatGPT, or Cursor to publish from there.', + 'connected' => 'You\'re connected.', + 'connected_description' => ':name is connected to TryPost.', + 'required' => 'Choose whether you\'ll schedule manually or with AI.', ], ]; diff --git a/lang/zh/posts.php b/lang/zh/posts.php index af5dc2750..69e817f68 100644 --- a/lang/zh/posts.php +++ b/lang/zh/posts.php @@ -591,80 +591,18 @@ 'cancel' => '取消', ], - 'create' => [ - 'title' => '创建一条新帖子', - 'description' => '选择你想如何开始。', - 'scratch_title' => '从头开始', - 'scratch_description' => '打开一条空白帖子,自己撰写全部内容。', - 'ai_title' => '用 AI 生成', - 'ai_description' => '描述你想要的内容,由 AI 为你生成。', - 'ai_configure_description' => '选择一种格式,并描述你想创建的帖子。', - 'ai_pick_template_description' => '为你的 AI 生成帖子选择一种风格。', - 'coming_soon' => '敬请期待', - - 'preview' => [ - 'image_title' => '图片标题', - 'image_body' => '图片正文', - ], - - 'steps' => [ - 'template_picker_title' => '选择一种风格', - 'format_title' => '选择一种格式', - 'format_description' => '选择你想创建的帖子类型。', - 'account_title' => '选择一个账号', - 'account_description' => '选择要发布到的社交账号。', - 'media_title' => '媒体选项', - 'media_carousel' => '要多少张幻灯片?', - 'media_optional' => '包含图片吗?', - 'media_optional_label' => '要多少张图片?', - 'media_none' => '无', - 'media_count_label' => '图片数量', - 'brand_colors_label' => '品牌颜色', - 'brand_colors_description' => '将工作区的品牌配色应用到生成的图片。关闭后由 AI 选择适合内容的颜色。', - 'prompt_title' => '描述你的帖子', - 'prompt_label' => '这条帖子讲的是什么?', - 'prompt_placeholder' => '例如 为 Instagram 宣布我们全新的轮播功能', - 'preview_error' => '出了点问题,请重试。', - 'loading_page_title' => '正在生成你的帖子', - 'loading_eta' => '预计时间:约 :minutes。', - 'loading_eta_minute_one' => '1 分钟', - 'loading_eta_minute_other' => ':count 分钟', - 'loading_leave_title' => '你可以继续工作。', - 'loading_leave_body' => '帖子准备就绪后,我们会通知你。', - 'loading_leave_cta' => '前往日历', - 'loading_create_another_cta' => '再创建一条帖子', - 'loading_tip_credits' => '每张 AI 图片大约消耗 15 个额度。', - 'loading_tip_edit' => '帖子准备就绪后,你将可以编辑一切。', - 'loading_tip_draft' => '生成的帖子会进入你的草稿。', - 'loading_tip_brand' => '调整你的品牌设置,以影响今后的帖子。', - 'loading_tip_carousel' => '轮播会为每张上传的图片生成一张幻灯片。', - 'loading_tip_quality' => '图片质量已设为在速度与成本之间取得平衡。', - 'create' => '创建帖子', - 'back' => '返回', - 'next' => '继续', - 'cancel' => '取消', - 'discard' => '放弃', - 'retry' => '重试', - 'no_platforms' => '没有已连接的账号', - 'connect_first' => '请至少连接一个社交账号以使用 AI 生成。', - 'no_account_for_template' => '连接一个兼容的账号以使用此模板。', - - 'format' => [ - 'instagram_feed' => 'Instagram 信息流帖子', - 'instagram_carousel' => 'Instagram 轮播', - 'linkedin_post' => 'LinkedIn 帖子', - 'linkedin_page_post' => 'LinkedIn 页面帖子', - 'x_post' => 'X 帖子', - 'bluesky_post' => 'Bluesky 帖子', - 'threads_post' => 'Threads 帖子', - 'mastodon_post' => 'Mastodon 帖子', - 'telegram_post' => 'Telegram 帖子', - 'discord_message' => 'Discord 消息', - 'facebook_post' => 'Facebook 帖子', - 'pinterest_pin' => 'Pinterest Pin', - 'instagram_story' => 'Instagram 快拍', - 'facebook_story' => 'Facebook 快拍', - ], - ], + /** Display names for the formats AI post generation can produce. */ + 'formats' => [ + 'instagram_feed' => 'Instagram 信息流帖子', + 'instagram_carousel' => 'Instagram 轮播', + 'linkedin_post' => 'LinkedIn 帖子', + 'linkedin_page_post' => 'LinkedIn 页面帖子', + 'x_post' => 'X 帖子', + 'bluesky_post' => 'Bluesky 帖子', + 'threads_post' => 'Threads 帖子', + 'mastodon_post' => 'Mastodon 帖子', + 'facebook_post' => 'Facebook 帖子', + 'pinterest_pin' => 'Pinterest Pin', + 'instagram_story' => 'Instagram 快拍', ], ]; diff --git a/lang/zh/sidebar.php b/lang/zh/sidebar.php index 23dc2e1a4..c066ec57a 100644 --- a/lang/zh/sidebar.php +++ b/lang/zh/sidebar.php @@ -26,8 +26,14 @@ ], 'analytics' => '分析', 'automations' => '自动化', - 'onboarding' => '开始使用', - 'onboarding_hint' => '完成设置', + 'browse' => '菜单', + 'chat' => '聊天', + 'chat_history' => '历史记录', + 'new_chat' => '新聊天', + 'no_chats' => '暂无聊天', + 'last_7_days' => '最近 7 天', + 'last_30_days' => '最近 30 天', + 'older' => '更早', 'posts' => [ 'calendar' => '日历', 'all' => '全部', diff --git a/lang/zh/welcome.php b/lang/zh/welcome.php deleted file mode 100644 index 2d3737224..000000000 --- a/lang/zh/welcome.php +++ /dev/null @@ -1,67 +0,0 @@ - '哪项最能描述你?', - 'description' => '选择最接近的一项,我们会为你定制体验。', - 'continue' => '继续', - 'subscription_required_title' => '等待账户所有者', - 'subscription_required_description' => '此账户还没有有效订阅。请让账户所有者完成结账 — 订阅生效后您即可获得完整访问权限。', - 'subscription_required_owner' => '您的账户所有者是 :name。', - 'subscription_required_auto' => '此页面会自动更新 — 无需刷新。', - 'progress' => '欢迎进度', - 'go_to_step' => '前往第 :step 步', - 'step_current' => '第 :step 步(当前)', - 'personas' => [ - 'creator' => '内容创作者', - 'freelancer' => '自由职业者', - 'developer' => '开发者', - 'startup' => '初创公司', - 'agency' => '代理机构', - 'small_business' => '小型企业', - 'marketer' => '营销人员', - 'online_store' => '网店', - 'other' => '其他', - ], - 'goals_title' => '你的目标是什么?', - 'goals_description' => '选择所有符合的选项,我们会为你配置好 TryPost。', - 'goals' => [ - 'save_time' => '一次发布到所有平台,节省时间', - 'ai_content' => '用 TryPost AI 生成帖子', - 'use_mcp' => '通过 Claude、ChatGPT 或 Cursor 创建帖子', - 'plan_calendar' => '在日历上规划我的帖子', - 'stay_on_brand' => '让每一条帖子都符合品牌调性', - 'grow_audience' => '增长我的受众和互动', - 'drive_sales' => '获得更多流量和销量', - 'manage_clients' => '管理多个品牌或客户', - 'just_exploring' => '目前只是随便看看', - 'other' => '其他需求', - ], - 'referral_source_title' => '您是如何找到我们的?', - 'referral_source_description' => '这有助于我们了解人们是如何发现 TryPost 的。', - 'referral_source' => [ - 'google' => 'Google', - 'x' => 'X (Twitter)', - 'linkedin' => 'LinkedIn', - 'youtube' => 'YouTube', - 'tiktok' => 'TikTok', - 'instagram' => 'Instagram', - 'threads' => 'Threads', - 'reddit' => 'Reddit', - 'product_hunt' => 'Product Hunt', - 'github' => 'GitHub', - 'hacker_news' => 'Hacker News', - 'directories' => '软件目录(G2、Capterra…)', - 'ai_assistant' => 'ChatGPT / Claude', - 'friend' => '朋友或同事', - 'founder' => '创始人', - 'blog' => 'Blog / newsletter', - 'other' => '其他', - ], - 'connect' => [ - 'title' => '连接社交账号', - 'description' => '选择至少一个 TryPost 可以发布内容的平台。', - 'required' => '请至少连接一个社交账号后再继续。', - ], -]; diff --git a/package-lock.json b/package-lock.json index 476825414..78a99e9c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "dependencies": { + "@ai-sdk/vue": "^4.0.68", "@codemirror/commands": "^6.10.3", "@codemirror/lang-json": "^6.0.2", "@codemirror/state": "^6.5.4", @@ -17,6 +18,7 @@ "@vue-flow/core": "^1.48.2", "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^12.8.2", + "ai": "^7.0.68", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -26,6 +28,7 @@ "highlight.js": "^11.11.1", "laravel-vite-plugin": "^3.1.0", "laravel-vue-i18n": "^2.8.0", + "markdown-it": "^15.0.0", "maska": "^3.2.0", "posthog-js": "^1.364.2", "reka-ui": "^2.9.10", @@ -74,6 +77,71 @@ "lightningcss-win32-x64-msvc": "^1.29.1" } }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.54", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.54.tgz", + "integrity": "sha512-x4fAXDqCtYzB/M5vsIQLYcyrzpJuaRgcIwDSw+lpTMMbgH19fU3ds75GSlHNLzfx6Z5yL4Z9+EMr0GJcqVy9QA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.7.tgz", + "integrity": "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.27", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.27.tgz", + "integrity": "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.7", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^7.28.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/vue": { + "version": "4.0.68", + "resolved": "https://registry.npmjs.org/@ai-sdk/vue/-/vue-4.0.68.tgz", + "integrity": "sha512-L72N4+rJKSEG0jX9TT0qkP13rFp6/q9M4G76XuBH1BAZgmtsnvyOhuwwnxxJOiEpkMp7IyogIw7PEcJNA7+LBw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "5.0.27", + "ai": "7.0.68", + "swrv": "^1.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "vue": "^3.3.4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1670,6 +1738,12 @@ "license": "MIT", "peer": true }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.18", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz", @@ -3170,6 +3244,15 @@ "win32" ] }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", @@ -3558,6 +3641,12 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -3593,6 +3682,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ai": { + "version": "7.0.68", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.68.tgz", + "integrity": "sha512-9QuZOT77wzoxxUC0NcueXhCo3HUHA/1pApIJ9VRyE+9/K+3Innkq4kVhrd9aEnwIviJz2Nga063m+UTsPSdOyw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "4.0.54", + "@ai-sdk/provider": "4.0.7", + "@ai-sdk/provider-utils": "5.0.27" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -5721,6 +5827,15 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6898,6 +7013,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -7296,6 +7417,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7382,6 +7522,61 @@ "vt-pbf": "^3.1.3" } }, + "node_modules/markdown-it": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz", + "integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/maska": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/maska/-/maska-3.2.0.tgz", @@ -7397,6 +7592,12 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8143,6 +8344,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pusher-js": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz", @@ -8938,6 +9148,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swrv": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/swrv/-/swrv-1.2.0.tgz", + "integrity": "sha512-lH/g4UcNyj+7lzK4eRGT4C68Q4EhQ6JtM9otPRIASfhhzfLWtbZPHcMuhuba7S9YVYuxkMUGImwMyGpfbkH07A==", + "license": "Apache-2.0", + "peerDependencies": { + "vue": ">=3.2.26 < 4" + } + }, "node_modules/tailwind-merge": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", @@ -9272,6 +9491,12 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9291,6 +9516,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -9934,6 +10168,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index a3c625a82..8d3e361a2 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "vue-tsc": "^2.2.4" }, "dependencies": { + "@ai-sdk/vue": "^4.0.68", "@codemirror/commands": "^6.10.3", "@codemirror/lang-json": "^6.0.2", "@codemirror/state": "^6.5.4", @@ -51,6 +52,7 @@ "@vue-flow/core": "^1.48.2", "@vue-flow/minimap": "^1.5.4", "@vueuse/core": "^12.8.2", + "ai": "^7.0.68", "axios": "^1.13.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -60,6 +62,7 @@ "highlight.js": "^11.11.1", "laravel-vite-plugin": "^3.1.0", "laravel-vue-i18n": "^2.8.0", + "markdown-it": "^15.0.0", "maska": "^3.2.0", "posthog-js": "^1.364.2", "reka-ui": "^2.9.10", diff --git a/resources/js/app.ts b/resources/js/app.ts index 6bdb68d69..45d1c7010 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -9,7 +9,9 @@ import { createApp, h } from 'vue'; import { initializeDataLayer } from './datalayer'; import dayjs from './dayjs'; +import { usesAppShell } from './lib/appShell'; import { syncContentTypeMediaRules } from './lib/contentTypeMediaRules'; +import AppShell from './layouts/AppShell.vue'; import { capturePageview, initializePostHog, syncPostHogContext } from './posthog'; import type { Auth } from './types'; @@ -17,6 +19,7 @@ const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it'; createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), + layout: (name) => (usesAppShell(name) ? AppShell : undefined), resolve: (name) => resolvePageComponent( `./pages/${name}.vue`, diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 29a0353e6..0c538a08c 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,5 +1,5 @@ + + diff --git a/resources/js/components/SidebarModeToggle.vue b/resources/js/components/SidebarModeToggle.vue new file mode 100644 index 000000000..b37486df8 --- /dev/null +++ b/resources/js/components/SidebarModeToggle.vue @@ -0,0 +1,66 @@ + + + diff --git a/resources/js/components/accounts/InstagramConnectDialog.vue b/resources/js/components/accounts/InstagramConnectDialog.vue index 5a8d027cf..7f3546e63 100644 --- a/resources/js/components/accounts/InstagramConnectDialog.vue +++ b/resources/js/components/accounts/InstagramConnectDialog.vue @@ -37,7 +37,11 @@ const showsFacebook = () => props.methods.includes(Platform.InstagramFacebook);