Просмотр исходного кода

WhatsApp messaging through Twilio or Meta, in one inbox with SMS and Telegram (#263)

* Add a provider-agnostic WhatsApp messaging layer

Workshops reach WhatsApp through whoever sells them a number, so the send and
receive path is defined by an adapter contract with Meta Cloud API and Twilio
behind it. A new provider is one file plus a registry entry: credentials are
declared by the adapter and stored under its own namespace, so no schema,
settings or route changes with it.

Handles the 24 hour service window explicitly, since a business may only send
free text within it and needs an approved template outside.

* Add WhatsApp settings and conversation actions

Settings render themselves from whichever adapter is selected, so a provider
brings its own credential fields without a form change. Secrets go out masked
and a masked value never overwrites the stored one.

Sending tells the caller whether the message had to go as a template, which is
what the compose box needs to warn about before anyone types.

* Put every messaging provider on one tabbed settings page

Email, SMS and Telegram each had their own sidebar entry, which read as a list
to scroll rather than a set to choose between, and WhatsApp would have made it
four. One Providers entry now opens tabs for all of them, with the active tab
in the URL so links and reloads land in the same place.

The old routes redirect, since docs, notifications and feature hints point at
them. A channel the plan does not include keeps its tab and explains itself.

* Add the WhatsApp inbox and let photos actually reach customers

Conversations get a thread list and a compose box that says up front when the
24 hour window has closed, rather than letting someone type a paragraph that
will arrive as a fixed template.

Attachments needed a way out: every upload sits behind protected routes, and a
provider fetches media from its own servers with no session of ours, so a photo
of a worn part would have arrived as an empty message. Sending now mints a
signed link for that one file, valid for an hour.

Server-only modules are aliased in vitest so they can be tested at all.

* Send a job photo to the customer, and schedule on WhatsApp

The photo button on a job's images is the reason a workshop wants this: the
mechanic has the seized caliper in front of them and the customer sees it
before anyone explains it over the phone. It hides itself when no WhatsApp
number is connected, rather than offering an action that only errors.

WhatsApp also joins the scheduled message channels, where the send path
already decides between free text and a template on its own.

* Give the customer page a WhatsApp tab

Sits beside SMS and Telegram and appears once a number is connected. The
conversation loads client-side, so the page only resolves the flag.

* Fold every channel into the Messages page

WhatsApp had its own sidebar entry next to Telegram and Messages, which is the
same fragmentation the settings page just lost: three entries for one activity.
Messages now carries a tab per channel the workshop actually has, beside the
scheduled queue, and the old routes redirect with their query intact so
notification links still open the right conversation.

Also fixes the scheduled-message channel label, which was missing because the
first pass only walked top-level translation keys.

* Merge the channels into one inbox with a badge per conversation

A workshop has one relationship with a customer and several ways of reaching
them, so the channel was the first thing the old tabs made you choose and the
last thing anyone cares about. One list now, sorted by recency, each row
labelled SMS, WhatsApp or Telegram, and replies go back out on the channel the
thread is on.

New message asks which channel only when there is more than one to pick from,
which also closes the trap where the button on a WhatsApp tab sent an SMS.

Threads stay per channel rather than per customer: the same person on SMS and
on WhatsApp has two histories on two transports, and merging them would put
replies on the wrong one.

* Drop the sidebar icon the WhatsApp entry left behind

* Drop the sidebar icon the Telegram entry left behind

* Page the inbox instead of capping it

The merge shipped with a fixed limit and no way past it, so a workshop with
more conversations than the cap simply could not reach the older ones. The
list now pages by keyset: each channel is asked for its newest page below a
timestamp cursor, and the merged result carries the cursor for the next one.

Keyset rather than offset because conversations move to the top as they are
answered, and an offset would skip or repeat rows every time one did.

Scrolling loads the next page on its own, with a button behind it for
keyboards. Search moved to the server too: filtering only the loaded page
would have answered "no results" for a conversation two pages down.

* Say what a provider means by "template", and stop the dead end

Twilio takes a Content SID, Meta takes a template name, and the form asked for
one thing without saying which, so a Meta-style name went to Twilio and came
back as "Invalid Parameter". Each adapter now declares what its template field
is called and what it should look like; the form renders that, hides the
language box for providers that ignore it, and saving refuses a value the
provider cannot accept.

The compose box also knows the difference between "this will go as a template"
and "nothing can be sent at all". With the window closed and no template set
up, it says so and disables itself instead of taking a message that will fail,
and the error names both ways out: have the customer write first, or approve
a template.

* Let a template use the shop's own data, and fix the stranded save button

A template's blanks were always filled with the typed message and nothing
else, so "Hallo Herr Meyer, Ihr Golf ist fertig" was not expressible. The
workshop now lists what fills each blank in order, from customer, vehicle,
plate, message and workshop, resolved at send time from the job the message
came from. An unknown value is refused when saving rather than reaching
WhatsApp as a literal word.

The save button sat inside the first card while the template fields lived in
the second, so editing a template and pressing Save saved everything except
the template. One button now, below every field it writes.

* Pick template values as chips instead of typing them

The words are ours, they are stored verbatim, and a misspelling failed
silently inside WhatsApp at send time rather than in the form. They are chips
now: click to add, each one numbered with the blank it fills, and every
available value listed with what it resolves to.

The vocabulary moved to a schema module so a client component can read it,
leaving the server-only file with just the part that queries for the values.

* Label template chips the way WhatsApp writes placeholders

A chip numbered 1 asked the reader to count blanks in their approved template;
one labelled {{1}} can be matched against it by eye.

* Send a template's photo the way Twilio actually takes it

A Twilio media template carries its own media placeholder, filled by a content
variable. The adapter was passing MediaUrl alongside the ContentSid instead,
which is not how the Content API takes it, so a photo sent outside the 24 hour
window would have gone missing.

The signed photo URL is now a template value like any other, so a workshop
adds a photo chip wherever its approved template expects the image.

* Put the photo variable inside the media URL, where providers accept it

Twilio validates a media template's URL field as a real URL and rejects a bare
variable, so the variable can only be the last segment of one. The photo value
now resolves to the signed token alone, and settings shows the URL prefix to
paste, with the variable left for the workshop to place.

* Treat Meta and Twilio template media the way each documents it

Verified against both providers' docs. Meta fixes only the media type when a
template is created and takes the image itself as a header parameter at send
time, so nothing is asked of the workshop. Twilio keeps the media URL in the
template and allows a variable only after the domain, so the workshop pastes
our prefix and we supply the final segment.

The adapter declares which of the two it is; the photo value and the URL to
paste are offered only to the provider that needs them.

* Answer the media URL with a sample image so a template can be approved

A provider asks for a sample value for every variable and fetches it during
review. Any made-up token would 403 against the signed media route and take
the template down with it, so one fixed address, /sample, returns a
placeholder image. Settings says to use it.

* Describe the template values as chips, not as a list to type

* Keep the WhatsApp settings locked until it is switched on

Every field below the toggle is now inside a disabled fieldset, so a workshop
cannot fill in credentials that will not be saved without also switching the
feature on. The switch itself stays live, along with a line saying what it
unlocks.

Also restores the provider-aware value list on the picker, lost to a later
edit: the photo value is offered only where the provider takes media as a URL
variable.

* Keep a template for text and a second one for photos

WhatsApp fixes a template's media type when it is approved, so the one media
template could never carry a plain text message, and a workshop writing from
the Messages page outside the window had nothing to send with.

Settings now holds both, and the send path picks by what is actually being
sent rather than by preference. The compose box follows: attaching a photo
switches which template it needs, and it says which one is missing.

* Say where each template is actually used

"When you send a photo" describes a case, not a place. Each template now names
the screens it serves: the text one covers writing from Messages, a customer's
WhatsApp tab and scheduled messages; the photo one covers the Send on WhatsApp
button on a job's photos and any attachment in a conversation.

An empty slot now says what it costs, since leaving one blank quietly closes
off a whole way of reaching customers.

* Link the WhatsApp settings to its setup guide

* Show the webhook URL for the provider being chosen

The URL was built from the saved provider, so switching the dropdown to Meta
still displayed the Twilio address, and pasting it into Meta failed
verification: that route has no verify handler and answers 403.

Settings now carry a URL per provider and the box follows the selection. It
also says to save before the provider verifies, since verification is checked
against stored settings and an unsaved form cannot pass.

* Let a half-finished WhatsApp setup save, so the webhook can be verified

Meta issues a phone number ID only after it has verified the callback URL, but
the form refused to save until that ID was present. Nothing could be stored,
so nothing could be verified, so the ID never arrived.

Saving now stores whatever is filled and reports what is still missing, on the
fields themselves as well as in a message. Sending stays unavailable until the
setup is complete, which the config loader already decided on its own.

Webhook verification reads its own lighter context: the provider named in the
URL and whatever is stored for it, without requiring a setup complete enough
to send with.

* Turn WhatsApp setup into a stepper, one step on screen at a time

Five steps in the order the providers impose: provider and number,
credentials, webhook, templates, test. Each says what it is for, links to the
exact page in that provider's console, and the webhook step shows whether the
provider has actually reached us yet.

Which step is showing is a cursor; whether a step is done is read from what is
stored, so returning later lands on real progress rather than a wizard reset
to the start. Adds a shadcn-style Stepper primitive for the header.

* Keep templates per provider, like credentials

A Twilio Content SID and a Meta template name are different kinds of thing,
but both were stored in one field. Selecting Meta on step 1 therefore
validated a Twilio SID against Meta's naming rules and refused to save
something the workshop had not touched.

Templates are now namespaced by provider, so switching provider shows that
provider's own templates and validates only those. Existing settings are read
through a fallback to the old flat keys, for the provider that wrote them.

* Make the send-photo button reachable on a phone, and mark messages read

The photo actions were revealed on hover, which a phone cannot do, so on the
device the mechanic takes the photo with the send button never appeared at
all. They now show by default and hide behind hover only where hovering
exists.

Inbound Meta messages are marked as read, so the customer sees blue ticks
rather than silence from a workshop that is in fact reading.

* Separate sending a photo from deleting it, and warn before a template fails

The two sat a thumb apart in the same corner, one of them destructive and the
other the one a mechanic uses daily. Delete keeps the corner; sending is now a
labelled action in the card footer.

Scheduling on WhatsApp also warns when no text template exists. A scheduled
message fires days later, when the customer's window has closed, so it can
only go as a template: without one it failed long after anyone could react.

* Explain what a template is before asking for one

The step asked for a Content SID and a list of values without ever saying what
a template is, why a provider has to approve one, or why there are two cards.
It now says all three, links to the guide, and each card carries a worked
example body with the values that fill it.

* Register a Meta number from the app instead of by hand

Meta requires a separate registration call once a number is verified, with a
six-digit PIN that becomes its two-step verification PIN. Its console does
this behind a button that reports only "Registration failed", and every
workshop taking the Meta path hits it.

Step 2 now offers it directly: enter a PIN, press Register, and Meta's own
error comes back with its numeric code rather than a shrug. The 10 attempts
per 72 hours limit is stated next to the button, since a wrong PIN counts
towards it and exhausting it locks the number out for three days.

* Mark outstanding setup steps in amber

A step still needing something looked the same as one not yet reached, so a
half-finished setup read as untouched. Outstanding steps now carry an amber
indicator and title, matching the amber already used for a field or template
that is still needed. The step being worked on keeps the primary highlight,
and finished ones keep their tick.

* Stop browsers autofilling credentials, and say when a secret is stored

Masked token fields made browsers read the block as a sign-in form and offer a
saved email into the field above, so every input here opts out of autofill and
password managers.

The eye on a saved secret revealed only the mask, since the real value never
leaves the server. It is now hidden until a new value is typed, with a line
saying the secret is saved and how to replace it.

Read more moves to the card's own action slot on the right.

* Stop a pasted secret landing beside its mask

A saved secret shows as sixteen bullets. Clicking in and pasting put the new
value after them, and because the field stops looking stored the moment it
differs from the mask, it switched to a password input and hid the bullets
among the dots. What reached Meta was mask plus token, rejected as error 190
with no hint that anything but the token was wrong.

Focusing a stored secret now empties it, so a paste has nothing to land beside,
and leaving without typing restores the mask and the stored value. Saving
strips stray bullets as well, which repairs values already saved that way.

Meta's error 190 says only "Cannot parse access token"; it now says to paste
the token again over the whole field and to prefer a permanent system user
token.

* Keep the browser from offering an email as a phone number ID

A password input anywhere in the card makes Chrome read the whole card as a
sign-in form, and it then offers the saved email into the text field above,
which is the phone number ID. autocomplete="off" is advisory and ignored for
what the browser believes is a login.

Secrets are masked in CSS now, so there is no password input to recognise and
nothing to autofill. The eye still reveals them.

Step 2 also links straight to system users, where the token that does not
expire is minted. It is in a different product from every other value on that
step, which is where the setup stalls.

* Show a conversation from a number we do not know yet

A first enquiry from a stranger is the message a workshop can least afford to
miss, and it arrives before anyone has created the customer. The thread was
listed, with its preview, but opening it said "Nothing here yet": the pane
loaded history by customer, and there was no customer. It now reads the
history by number when that is all we have. Replying still needs a customer.

Console links become text links rather than outline buttons, and Meta's steps
carry the click path with them. Meta's pages hang off an app id we never
collect, so a link can only reach the app list; the rest is written out.
Templates point at the current WhatsApp Manager address.

* File a message under the customer whose number it is

An outbound message only got a customer when the caller named one, so a test
send, addressed by number alone, sat in its own thread beside the customer who
owns that number. It is now matched the same way an inbound message is, trying
every shape the number might be stored in.

Messages that arrived before the customer existed are claimed when someone is
created or edited with that number. The app tells you to add an unknown caller
as a customer in order to reply, so the history that prompted it should follow
them across rather than stay stranded.

A conversation held under a bare number can also be deleted now. Delete was
keyed on the customer, which left exactly the threads most worth clearing away
(test sends, wrong numbers) with no menu at all.

* Make links in a message clickable, and stop long ones overflowing

A setup link from WhatsApp ran past the edge of the pane as inert text. Message
bodies now render with their links as links and with WhatsApp's own bold,
italic and strikethrough markers honoured, and a long address wraps rather than
widening the bubble.

Every piece is rendered as an element rather than as markup, so nothing a
sender writes becomes HTML, and only http and https are ever linked. SMS and
Telegram share the same renderer, since their bodies carry links too.

* Stop asking Meta users to fill a blank the photo does not need

The photo template's hint said to fill it with customer, message and photo,
which is true of Twilio and not of Meta: Meta carries the image in the header
and attaches it at send time, so no photo chip is offered. People were left
looking for a chip that is deliberately hidden, or wondering what the second
blank was for. Providers that take media in the header now say the photo is
attached automatically.

* Give the inbox the height it actually has, and a way in

The pane was sized calc(100vh-11rem), a guess at the height of everything above
it. When that guess ran short the composer fell outside the card and was cut
off, leaving a conversation you could read but not reply to. The page is now
bounded by the viewport and the pane fills what is left, the same pattern the
settings and quote pages use.

New message was offered only by the empty state, so it vanished as soon as
there was anything to read. It now sits in the conversations header, where it
is reachable whether or not a thread is open.

* Say why a message to your own number cannot be sent

WhatsApp refuses to carry a message from a number to itself and answers with a
code rather than a reason. Workshops meet this while testing, because the
obvious number to try is the one they have just registered. Torqvoice now stops
before the provider does and says to use a different phone.

* Tell the SMS pane who it is talking to

The inbox read a customer name and phone number off the conversation action,
which never returned either, then cast the result to a shape that said it did.
So every SMS thread opened on "No phone number on file for ." with an empty
name and no way to reply. Telegram had the same hole: the chat id a reply is
sent to was invented by the same cast.

Both actions now return the person alongside the messages, and the pane takes
its types from what the actions actually return, so the compiler catches the
next drift instead of the customer doing it.

The WhatsApp scroll region also gets min-h-0, without which a tall photo could
not shrink and pushed the composer out of the card.

* Open a WhatsApp thread at its newest message

The pane scrolled once on mount, which ran before the messages had loaded and
before the list replaced its spinner, so it scrolled an empty container and
left the reader at the oldest message. It now scrolls when the messages are on
screen, and a later message slides down smoothly.

A photo has no height until it loads, so a thread ending in one settled just
short of the bottom. The pane follows that growth, but only while the reader is
already at the newest message.

* Read 00 in a phone number as the + it stands for

A number saved as 004747604248 never reached the international branch, so it
was treated as a local number: the leading zeros came off and the workshop's
country code went on. With no country set that failed outright, which is what a
workshop saw; with one set it silently produced a different number that still
looked valid.

00 is the international prefix people dial instead of +, and no national number
begins with it, so it is now read as +. Customer lookup also tries the 00 form,
since that is how plenty of numbers were typed in.

The normaliser had no tests. It has nine now, including the case that turned a
Norwegian number into a longer one without complaining.

* Say which setting completes a number without a country code

A local number is not wrong, it is incomplete, and the workshop completes it
either on the customer or once in Settings. "Not a valid phone number" named
neither, and pointed at the number, which was usually fine. SMS already said
this properly; WhatsApp now matches, and only says it when the country code is
actually what is missing.

* Link step 1 to where the sender number lives

The first step asks for the number to send from, and on Twilio that is a sender
configured in their console, not something a workshop knows by heart. The step
linked nowhere, so the only way to look it up was to skip ahead to step 2. It
now carries a link straight to the WhatsApp senders page.

Meta gets no such link: that number belongs to the workshop and is theirs to
know, so the field is left declaring only what it needs.

* Call it Template SID, the way Twilio's console does

Twilio renamed the value in its console; the API still receives it as
ContentSid, but the console is where a workshop is looking when they copy it,
so the field, its help, its validation message and the guide in all twelve
locales now use the name they will actually see.

* Refuse the last few demo actions that reach outside

Most sending already refused to run on the demo, but three did not: a status
report, which goes to a customer by email, SMS or Telegram; disconnecting
Telegram, where every other provider guards the same thing; and activating a
licence key, which would move a real customer's plan onto a shared instance.

The guards were added by hand and one sat missing for months, so a test now
reads the source and fails if an action that reaches a customer lacks one. It
was checked against a deliberately removed guard rather than only its passing.

* Mark an unfinished step in a colour the brand does not already use

Amber was invisible as a signal because the product is amber: the primary
button and the active step wear the same hue, so the one marker meant to stand
out read as decoration. Outstanding steps are now filled solid rose, which
appears nowhere else, and the inline hints for a missing field, an empty
template and an unverified webhook match it. Genuine cautions, like Meta's ten
attempts in 72 hours, stay amber, so the two now mean different things.

The step marker also grew: a wider ring, a heavier border and a bolder number,
sized so the row does not shift.

* colors

* Let the step you are on be the only loud thing

Three markers were competing. The active step, a solid amber, was the same
weight as a solid green tick beside it, and the outstanding one was a tenth of
an opacity and vanished. Contrast was the problem, not hue.

Now the step being worked on is the only filled marker, with a ring. A finished
step is an outlined emerald tick, calm because it needs no action. An
outstanding one keeps amber but at full-strength outline with dark text, which
is what it always lacked. Neither relies on colour alone: done shows a check,
and the label beside an outstanding step is emphasised too.

* Link Meta's number list from step 1 as well

Step 1 asks for the sending number and only Twilio linked anywhere. The
argument for leaving Meta out, that the number is the workshop's own, ignored
the phone number ID sitting beside it in WhatsApp Manager, which is exactly
what the next step asks for.

* Offer the WhatsApp providers as a row, the way SMS does

SMS lays its providers out as buttons across the width; WhatsApp hid the same
choice behind a dropdown, so the two settings pages taught different habits for
the same decision. With two options there is nothing to gain by hiding either.

The row is built from the registry, so a third provider appears without
touching this file, and the dropdown's placeholder string goes with it.
Bernt Christian Egeland 1 месяц назад
Родитель
Сommit
a5ab2c416a
100 измененных файлов с 3249 добавлено и 96 удалено
  1. 4 2
      messages/de/audit.json
  2. 2 1
      messages/de/customers.json
  3. 4 1
      messages/de/messages.json
  4. 26 0
      messages/de/messaging.json
  5. 4 2
      messages/de/navigation.json
  6. 4 2
      messages/de/scheduledMessages.json
  7. 30 0
      messages/de/settings.json
  8. 179 0
      messages/de/whatsapp.json
  9. 4 2
      messages/en/audit.json
  10. 2 1
      messages/en/customers.json
  11. 4 1
      messages/en/messages.json
  12. 26 0
      messages/en/messaging.json
  13. 4 2
      messages/en/navigation.json
  14. 4 2
      messages/en/scheduledMessages.json
  15. 30 0
      messages/en/settings.json
  16. 179 0
      messages/en/whatsapp.json
  17. 4 2
      messages/es/audit.json
  18. 2 1
      messages/es/customers.json
  19. 4 1
      messages/es/messages.json
  20. 26 0
      messages/es/messaging.json
  21. 4 2
      messages/es/navigation.json
  22. 4 2
      messages/es/scheduledMessages.json
  23. 30 0
      messages/es/settings.json
  24. 179 0
      messages/es/whatsapp.json
  25. 4 2
      messages/fr/audit.json
  26. 2 1
      messages/fr/customers.json
  27. 4 1
      messages/fr/messages.json
  28. 26 0
      messages/fr/messaging.json
  29. 4 2
      messages/fr/navigation.json
  30. 4 2
      messages/fr/scheduledMessages.json
  31. 30 0
      messages/fr/settings.json
  32. 179 0
      messages/fr/whatsapp.json
  33. 4 2
      messages/it/audit.json
  34. 2 1
      messages/it/customers.json
  35. 4 1
      messages/it/messages.json
  36. 26 0
      messages/it/messaging.json
  37. 4 2
      messages/it/navigation.json
  38. 4 2
      messages/it/scheduledMessages.json
  39. 30 0
      messages/it/settings.json
  40. 179 0
      messages/it/whatsapp.json
  41. 4 2
      messages/lt/audit.json
  42. 2 1
      messages/lt/customers.json
  43. 4 1
      messages/lt/messages.json
  44. 26 0
      messages/lt/messaging.json
  45. 4 2
      messages/lt/navigation.json
  46. 4 2
      messages/lt/scheduledMessages.json
  47. 30 0
      messages/lt/settings.json
  48. 179 0
      messages/lt/whatsapp.json
  49. 4 2
      messages/nb/audit.json
  50. 2 1
      messages/nb/customers.json
  51. 4 1
      messages/nb/messages.json
  52. 26 0
      messages/nb/messaging.json
  53. 4 2
      messages/nb/navigation.json
  54. 4 2
      messages/nb/scheduledMessages.json
  55. 30 0
      messages/nb/settings.json
  56. 179 0
      messages/nb/whatsapp.json
  57. 4 2
      messages/nl/audit.json
  58. 2 1
      messages/nl/customers.json
  59. 4 1
      messages/nl/messages.json
  60. 26 0
      messages/nl/messaging.json
  61. 4 2
      messages/nl/navigation.json
  62. 4 2
      messages/nl/scheduledMessages.json
  63. 30 0
      messages/nl/settings.json
  64. 179 0
      messages/nl/whatsapp.json
  65. 4 2
      messages/pl/audit.json
  66. 2 1
      messages/pl/customers.json
  67. 4 1
      messages/pl/messages.json
  68. 26 0
      messages/pl/messaging.json
  69. 4 2
      messages/pl/navigation.json
  70. 4 2
      messages/pl/scheduledMessages.json
  71. 30 0
      messages/pl/settings.json
  72. 179 0
      messages/pl/whatsapp.json
  73. 4 2
      messages/pt-BR/audit.json
  74. 2 1
      messages/pt-BR/customers.json
  75. 4 1
      messages/pt-BR/messages.json
  76. 26 0
      messages/pt-BR/messaging.json
  77. 4 2
      messages/pt-BR/navigation.json
  78. 4 2
      messages/pt-BR/scheduledMessages.json
  79. 30 0
      messages/pt-BR/settings.json
  80. 179 0
      messages/pt-BR/whatsapp.json
  81. 4 2
      messages/ru/audit.json
  82. 2 1
      messages/ru/customers.json
  83. 4 1
      messages/ru/messages.json
  84. 26 0
      messages/ru/messaging.json
  85. 4 2
      messages/ru/navigation.json
  86. 4 2
      messages/ru/scheduledMessages.json
  87. 30 0
      messages/ru/settings.json
  88. 179 0
      messages/ru/whatsapp.json
  89. 4 2
      messages/tr/audit.json
  90. 2 1
      messages/tr/customers.json
  91. 4 1
      messages/tr/messages.json
  92. 26 0
      messages/tr/messaging.json
  93. 4 2
      messages/tr/navigation.json
  94. 4 2
      messages/tr/scheduledMessages.json
  95. 30 0
      messages/tr/settings.json
  96. 179 0
      messages/tr/whatsapp.json
  97. 39 0
      prisma/migrations/20260821134531_whatsapp_messages/migration.sql
  98. 51 0
      prisma/schema.prisma
  99. 73 0
      src/__tests__/features/messaging/inbox-paging.test.ts
  100. 50 0
      src/__tests__/features/messaging/message-text.test.ts

+ 4 - 2
messages/de/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Satz erneut eingelagert",
     "tire_set_dispose": "Reifensatz ausgemustert",
     "tire_set_attach": "Dateien zu einem Reifensatz hinzugefügt",
-    "tire_set_detach": "Datei von einem Reifensatz entfernt"
+    "tire_set_detach": "Datei von einem Reifensatz entfernt",
+    "whatsapp_send": "WhatsApp-Nachricht gesendet"
   },
   "summary": {
     "customField_create": "Benutzerdefiniertes Feld „{name}“ erstellt",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Fahrzeug {id} aus dem Archiv geholt",
     "vehicle_update": "Fahrzeug {name} aktualisiert, geändert: {fields}",
     "vehicle_updateNoChanges": "Fahrzeug {name} aktualisiert, nichts geändert",
-    "webhook_create": "Webhook {name} erstellt"
+    "webhook_create": "Webhook {name} erstellt",
+    "whatsapp_send": "WhatsApp-Nachricht an {name} gesendet"
   }
 }

+ 2 - 1
messages/de/customers.json

@@ -38,7 +38,8 @@
       "messages": "Nachrichten",
       "requests": "Anfragen ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Kennzeichen",

+ 4 - 1
messages/de/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Senden und empfangen Sie SMS-Nachrichten mit Ihren Kunden direkt über Torqvoice.",
     "tabs": {
       "inbox": "Posteingang",
-      "scheduled": "Geplant"
+      "scheduled": "Geplant",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS ist nicht eingerichtet",
     "smsNotConfiguredHint": "Verbinden Sie einen SMS-Anbieter, um hier Nachrichten zu senden und zu empfangen. Geplante E-Mails funktionieren auch ohne.",

+ 26 - 0
messages/de/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Konversationen",
+    "searchPlaceholder": "Konversationen durchsuchen",
+    "noSearchResults": "Keine Konversation passt zur Suche.",
+    "empty": "Noch keine Konversationen.",
+    "selectConversation": "Konversation auswählen",
+    "back": "Zurück",
+    "newMessage": "Neue Nachricht",
+    "unknownContact": "Diese Nummer gehört noch zu keinem Kunden. Legen Sie ihn als Kunden an, um zu antworten.",
+    "noChannelsTitle": "Kein Nachrichtenkanal eingerichtet",
+    "noChannelsHint": "Verbinden Sie SMS, WhatsApp oder Telegram, um von hier aus zu schreiben.",
+    "goToProviders": "Anbieter einrichten",
+    "chooseChannel": {
+      "title": "Über welchen Kanal senden?",
+      "description": "Sie haben mehrere Wege, Ihre Kunden zu erreichen."
+    },
+    "deleteConversation": "Konversation löschen",
+    "deleteTitle": "Diese Konversation löschen?",
+    "deleteDescription": "Entfernt Ihre Kopie der Konversation mit {name}. Beim Kunden bleiben die Nachrichten erhalten.",
+    "deleteFailed": "Konversation konnte nicht gelöscht werden",
+    "delete": "Löschen",
+    "cancel": "Abbrechen",
+    "loadMore": "Mehr laden"
+  }
+}

+ 4 - 2
messages/de/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Alle Reifensätze",
     "tireStorage": "Lagerstruktur",
     "tireSetDetails": "Reifensatz",
-    "tireForecast": "Nächste Saison"
+    "tireForecast": "Nächste Saison",
+    "whatsapp": "WhatsApp"
   },
   "search": "Suchen...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Mehr",
     "auditLog": "Aktivitätsprotokoll",
     "aiAssistant": "KI-Assistent",
-    "tireHotel": "Reifenhotel"
+    "tireHotel": "Reifenhotel",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/de/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-Mail",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Notiz in der App"
+      "in_app": "Notiz in der App",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Wiederholen",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Planen",
     "scheduled": "Nachricht geplant",
     "updated": "Geplante Nachricht aktualisiert",
-    "saveError": "Nachricht konnte nicht gespeichert werden"
+    "saveError": "Nachricht konnte nicht gespeichert werden",
+    "whatsappNeedsTemplate": "Diese Nachricht geht lange nach Ablauf des 24-Stunden-Fensters raus, und es ist keine WhatsApp-Textvorlage eingerichtet, daher würde sie beim Senden scheitern. Richten Sie eine unter Einstellungen > Anbieter > WhatsApp ein."
   },
   "list": {
     "scheduleMessage": "Nachricht planen",

+ 30 - 0
messages/de/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Reifenhotel",
         "description": "Saisonale Reifeneinlagerung"
+      },
+      "providers": {
+        "title": "Anbieter",
+        "description": "E-Mail, SMS, WhatsApp & Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Preise für Vorbereitung",
     "prepHint": "Was jede Art von Vorbereitung kostet. Leer bedeutet, sie wird nie berechnet, was zu Betrieben passt, die das Waschen in der Lagergebühr enthalten haben. Bepreiste werden automatisch auf Angebot und Auftrag gesetzt.",
     "prepNotCharged": "nicht berechnet"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-Mail",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "E-Mail-Einstellungen",
+        "description": "SMTP-Versand einrichten, um Rechnungen, Angebote und Benachrichtigungen direkt aus der Werkstatt zu senden."
+      },
+      "sms": {
+        "feature": "SMS-Nachrichten",
+        "description": "SMS mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
+      },
+      "whatsapp": {
+        "feature": "WhatsApp-Nachrichten",
+        "description": "WhatsApp-Nachrichten samt Fotos von Bauteilen direkt aus Torqvoice senden und empfangen."
+      },
+      "telegram": {
+        "feature": "Telegram-Nachrichten",
+        "description": "Telegram-Nachrichten mit Ihren Kunden direkt aus Torqvoice senden und empfangen."
+      }
+    }
   }
 }

+ 179 - 0
messages/de/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "WhatsApp-Nachrichten mit Ihren Kunden senden und empfangen.",
+    "helpLink": "{provider} einrichten",
+    "save": "WhatsApp-Einstellungen speichern",
+    "saved": "WhatsApp-Einstellungen gespeichert",
+    "saveError": "WhatsApp-Einstellungen konnten nicht gespeichert werden",
+    "enable": {
+      "label": "WhatsApp aktivieren",
+      "hint": "Einschalten, sobald die Zugangsdaten des Anbieters hinterlegt sind.",
+      "disabledInfo": "Schalten Sie WhatsApp ein, um Anbieter, Nummer und Vorlage einzurichten."
+    },
+    "provider": {
+      "label": "Anbieter",
+      "hint": "Wer Ihre WhatsApp-Nummer bereitstellt. Beim Wechsel bleiben die Zugangsdaten des alten Anbieters erhalten."
+    },
+    "from": {
+      "label": "Ihre WhatsApp-Nummer",
+      "hint": "Die Nummer, die Kunden sehen, im internationalen Format."
+    },
+    "secret": {
+      "show": "Anzeigen",
+      "hide": "Verbergen",
+      "stored": "Gespeichert und verborgen. Neuen Wert eingeben, um ihn zu ersetzen."
+    },
+    "webhook": {
+      "label": "Webhook-URL",
+      "description": "Diese URL beim Anbieter eintragen, damit eingehende Nachrichten in Torqvoice ankommen.",
+      "copy": "Webhook-URL kopieren",
+      "copied": "Webhook-URL kopiert",
+      "saveFirst": "Speichern Sie diese Einstellungen, bevor Ihr Anbieter die URL prüft. Geprüft wird gegen das hier Gespeicherte, ein ungespeichertes Formular fällt durch."
+    },
+    "template": {
+      "title": "Nachrichtenvorlage",
+      "description": "Wird verwendet, wenn Sie einem Kunden schreiben, der sich länger nicht gemeldet hat.",
+      "windowExplainer": "WhatsApp erlaubt freien Text nur 24 Stunden nach der letzten Kundennachricht. Danach muss Ihre Nachricht als vom Anbieter freigegebene Vorlage rausgehen.",
+      "nameLabel": "Name der Vorlage",
+      "nameHint": "Name der freigegebenen Vorlage, bei manchen Anbietern die Content-SID.",
+      "languageLabel": "Sprache der Vorlage",
+      "languageHint": "Sprachcode, unter dem die Vorlage freigegeben wurde, zum Beispiel de.",
+      "variablesLabel": "Werte der Vorlage, in Reihenfolge",
+      "variablesHint": "Wählen Sie die Werte, die die nummerierten Platzhalter Ihrer Vorlage füllen, in derselben Reihenfolge. Leer lassen, wenn die Vorlage keine Platzhalter hat.",
+      "tokens": {
+        "customer": "Kundenname",
+        "vehicle": "Baujahr, Marke und Modell",
+        "plate": "Kennzeichen",
+        "message": "Ihr Text",
+        "workshop": "Name Ihrer Werkstatt",
+        "photo": "das angehängte Foto"
+      },
+      "variablesEmpty": "Noch keine Werte. Unten in der Reihenfolge Ihrer Vorlage hinzufügen.",
+      "variablesRemove": "{name} entfernen",
+      "mediaUrlLabel": "Medien-URL für Ihre Vorlage",
+      "mediaUrlHint": "Fügen Sie dies in das Medienfeld der Vorlage ein und ersetzen Sie n durch die Nummer des Foto-Werts.",
+      "mediaUrlCopy": "Medien-URL kopieren",
+      "mediaUrlCopied": "Medien-URL kopiert",
+      "mediaUrlSample": "Als Beispielwert beim Anbieter verwenden: sample",
+      "textTitle": "Textnachrichten",
+      "textDescription": "Wird verwendet, wenn Sie ohne Foto schreiben: aus Nachrichten, aus dem WhatsApp-Tab beim Kunden oder aus einer geplanten Nachricht.",
+      "mediaTitle": "Nachrichten mit Foto",
+      "mediaDescription": "Wird vom Button „Per WhatsApp senden“ bei den Fotos eines Auftrags verwendet und immer, wenn Sie in einer Konversation ein Foto anhängen.",
+      "textMissing": "Nicht eingerichtet: Sie können Kunden, die sich seit 24 Stunden nicht gemeldet haben, nicht schreiben.",
+      "mediaMissing": "Nicht eingerichtet: Sie können Kunden, die sich seit 24 Stunden nicht gemeldet haben, keine Fotos senden.",
+      "whatIsIt": "Eine Vorlage ist eine feste Nachricht, die Sie einmal schreiben und die {provider} freigibt: Der Wortlaut ändert sich nie, nur die nummerierten Platzhalter füllen Sie. Schreiben Sie sie in deren Konsole und fügen Sie hier die Kennung ein.",
+      "whyTwo": "Zwei sind nötig, weil WhatsApp den Medientyp bei der Freigabe festlegt. Eine mit Foto freigegebene Vorlage kann nur Fotos tragen, eine als Text freigegebene nie eines.",
+      "exampleLabel": "Beispieltext für die Konsole Ihres Anbieters",
+      "exampleTextValues": "Zu füllen mit: customer, message. Schreiben Sie ihn in der Sprache Ihrer Kunden.",
+      "exampleMediaValues": "Zu füllen mit: customer, message und photo für die Medien-URL. Schreiben Sie ihn in der Sprache Ihrer Kunden.",
+      "exampleMediaValuesHeader": "Füllen Sie sie mit: customer, message. Das Foto wird automatisch angehängt und braucht keine eigene Lücke. Schreiben Sie sie in der Sprache Ihrer Kundschaft."
+    },
+    "test": {
+      "send": "Test senden",
+      "sent": "Testnachricht gesendet",
+      "failed": "Testnachricht konnte nicht gesendet werden",
+      "hint": "Schreiben Sie sich zuerst selbst über WhatsApp an, sonst braucht der Test eine freigegebene Vorlage."
+    },
+    "disconnect": {
+      "action": "WhatsApp trennen",
+      "hint": "Entfernt die Zugangsdaten aus Torqvoice. Ihre Konversationen bleiben erhalten.",
+      "done": "WhatsApp getrennt",
+      "title": "Trennen"
+    },
+    "savedIncomplete": "Gespeichert. Zum Senden fehlt noch: {fields}",
+    "fieldStillNeeded": "Zum Senden noch erforderlich",
+    "steps": {
+      "openProvider": "{provider} öffnen",
+      "openNumber": "Absendernummer finden",
+      "openToken": "Dauerhaftes Token erstellen",
+      "consolePath": "Gehen Sie in Meta zu:",
+      "provider": {
+        "title": "Anbieter und Nummer wählen",
+        "description": "Twilio ist schneller eingerichtet und übernimmt Meta für Sie. Die Meta Cloud API kostet weniger, dafür durchlaufen Sie die Unternehmensverifizierung selbst. Tragen Sie die Nummer ein, die Kunden sehen, im internationalen Format."
+      },
+      "credentials": {
+        "title": "Zugangsdaten eintragen",
+        "description": "Kopieren Sie jeden Wert aus {provider}. Jedes Feld unten sagt genau, wo er zu finden ist. Speichern Sie, sobald Sie sie haben; den Rest können Sie später ergänzen."
+      },
+      "webhook": {
+        "title": "Webhook eintragen",
+        "description": "Kopieren Sie diese Adresse zu {provider}, damit Nachrichten von Kunden bei Torqvoice ankommen. Speichern Sie zuerst diese Seite: Die Adresse wird gegen das hier Gespeicherte geprüft.",
+        "open": "Webhook-Einstellungen bei {provider} öffnen",
+        "recheck": "Erneut prüfen",
+        "waiting": "An dieser Adresse ist noch nichts angekommen. Sie wird grün, sobald Ihr Anbieter sie bestätigt oder eine Nachricht zustellt.",
+        "seen": "Ihr Anbieter hat diese Adresse erreicht. Eingehende Nachrichten erscheinen unter Nachrichten."
+      },
+      "templates": {
+        "title": "Vorlagen freigeben lassen",
+        "description": "Nur nötig, um außerhalb des 24-Stunden-Fensters zuerst zu schreiben. Sie können das überspringen und zurückkommen, sobald der Nachrichtenversand läuft.",
+        "open": "Vorlagen bei {provider} öffnen"
+      },
+      "test": {
+        "title": "Testnachricht senden",
+        "description": "Schreiben Sie zuerst vom eigenen Handy an Ihre WhatsApp-Nummer. Das beweist, dass der Webhook funktioniert, und öffnet ein 24-Stunden-Fenster, sodass eine Antwort keine Vorlage braucht."
+      },
+      "back": "Zurück",
+      "continue": "Weiter"
+    },
+    "register": {
+      "title": "Nummer bei Meta registrieren",
+      "description": "Meta verlangt nach der Verifizierung eine gesonderte Registrierung. Wählen Sie eine sechsstellige PIN, oder nehmen Sie die vorhandene PIN der Bestätigung in zwei Schritten. Notieren Sie sie: Sie brauchen sie, um die Nummer später zu verschieben oder neu zu registrieren.",
+      "action": "Nummer registrieren",
+      "done": "Nummer registriert",
+      "failed": "Nummer konnte nicht registriert werden",
+      "limit": "Meta erlaubt 10 Registrierungsversuche pro Nummer in 72 Stunden. Eine falsche PIN zählt mit, prüfen Sie sie also vorher."
+    }
+  },
+  "messages": {
+    "featureName": "WhatsApp-Nachrichten",
+    "featureDescription": "WhatsApp-Nachrichten samt Fotos von Bauteilen direkt aus Torqvoice senden und empfangen.",
+    "noThreads": "Noch keine Konversationen",
+    "noThreadsHint": "Nachrichten erscheinen hier, sobald ein Kunde an Ihre WhatsApp-Nummer schreibt oder Sie ihm schreiben.",
+    "selectThread": "Konversation auswählen",
+    "backToThreads": "Konversationen",
+    "empty": "Hier ist noch nichts.",
+    "placeholder": "Nachricht schreiben",
+    "send": "Senden",
+    "sendFailed": "Nachricht konnte nicht gesendet werden",
+    "attach": "Foto anhängen",
+    "attachFailed": "Foto konnte nicht angehängt werden",
+    "removeAttachment": "Anhang entfernen",
+    "attachment": "Anhang",
+    "delete": "Löschen",
+    "deleteFailed": "Nachricht konnte nicht gelöscht werden",
+    "sentAsTemplate": "Vorlage",
+    "sentAsTemplateNotice": "Als freigegebene Vorlage gesendet, weil der Kunde in den letzten 24 Stunden nicht geschrieben hat.",
+    "windowClosed": "Der Kunde hat in den letzten 24 Stunden nicht geschrieben, daher geht dies als freigegebene Vorlage raus.",
+    "unknownNumber": "Diese Nummer gehört noch zu keinem Kunden. Legen Sie ihn als Kunden an, um zu antworten.",
+    "notConfigured": {
+      "title": "WhatsApp ist nicht verbunden",
+      "description": "Verbinden Sie eine WhatsApp-Nummer, um Nachrichten mit Ihren Kunden auszutauschen.",
+      "action": "WhatsApp einrichten"
+    },
+    "title": "Konversationen",
+    "searchPlaceholder": "Konversationen durchsuchen",
+    "noSearchResults": "Keine Konversation passt zur Suche.",
+    "compose": {
+      "newMessage": "Neue Nachricht",
+      "title": "Neue WhatsApp-Nachricht",
+      "searchPlaceholder": "Kunden suchen",
+      "noMatches": "Kein Kunde passt zur Suche.",
+      "noRecipients": "Noch kein Kunde mit Telefonnummer."
+    },
+    "windowBlocked": "Dieser Kunde hat in den letzten 24 Stunden nicht geschrieben, daher stellt WhatsApp jetzt keine Nachricht zu. Bitten Sie ihn zu schreiben, oder richten Sie eine freigegebene Vorlage ein.",
+    "windowBlockedAction": "Vorlage einrichten",
+    "windowBlockedPlaceholder": "Warten, bis der Kunde zuerst schreibt"
+  },
+  "sendPhoto": {
+    "action": "Per WhatsApp senden",
+    "title": "Foto per WhatsApp senden",
+    "description": "Der Kunde erhält dieses Foto in seinem WhatsApp-Chat.",
+    "captionPlaceholder": "Hinweis ergänzen, zum Beispiel was das Foto zeigt",
+    "send": "Senden",
+    "cancel": "Abbrechen",
+    "sent": "Foto gesendet",
+    "sentAsTemplate": "Foto als freigegebene Vorlage gesendet",
+    "failed": "Foto konnte nicht gesendet werden"
+  }
+}

+ 4 - 2
messages/en/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Stored a set again",
     "tire_set_dispose": "Wrote off a tire set",
     "tire_set_attach": "Added files to a tire set",
-    "tire_set_detach": "Removed a file from a tire set"
+    "tire_set_detach": "Removed a file from a tire set",
+    "whatsapp_send": "Sent WhatsApp Message"
   },
   "summary": {
     "customField_create": "Created custom field \"{name}\"",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Unarchived vehicle {id}",
     "vehicle_update": "Updated vehicle {name}, changed: {fields}",
     "vehicle_updateNoChanges": "Updated vehicle {name}, nothing changed",
-    "webhook_create": "Created webhook {name}"
+    "webhook_create": "Created webhook {name}",
+    "whatsapp_send": "Sent a WhatsApp message to {name}"
   }
 }

+ 2 - 1
messages/en/customers.json

@@ -38,7 +38,8 @@
       "messages": "Messages",
       "requests": "Requests ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Plate",

+ 4 - 1
messages/en/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Send and receive SMS messages with your customers directly from Torqvoice.",
     "tabs": {
       "inbox": "Inbox",
-      "scheduled": "Scheduled"
+      "scheduled": "Scheduled",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS is not set up",
     "smsNotConfiguredHint": "Connect an SMS provider to send and receive texts here. Scheduled email still works without it.",

+ 26 - 0
messages/en/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Conversations",
+    "searchPlaceholder": "Search conversations",
+    "noSearchResults": "No conversations match your search.",
+    "empty": "No conversations yet.",
+    "selectConversation": "Pick a conversation",
+    "back": "Back",
+    "newMessage": "New message",
+    "unknownContact": "This number belongs to no customer yet. Add them as a customer to reply.",
+    "noChannelsTitle": "No messaging channel set up",
+    "noChannelsHint": "Connect SMS, WhatsApp or Telegram to write to your customers from here.",
+    "goToProviders": "Set up a provider",
+    "chooseChannel": {
+      "title": "Send on which channel?",
+      "description": "You have more than one way to reach your customers."
+    },
+    "deleteConversation": "Delete conversation",
+    "deleteTitle": "Delete this conversation?",
+    "deleteDescription": "Removes your copy of the conversation with {name}. The messages stay on their phone.",
+    "deleteFailed": "Could not delete the conversation",
+    "delete": "Delete",
+    "cancel": "Cancel",
+    "loadMore": "Load more"
+  }
+}

+ 4 - 2
messages/en/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "All Tire Sets",
     "tireStorage": "Storage Layout",
     "tireSetDetails": "Tire Set",
-    "tireForecast": "Next season"
+    "tireForecast": "Next season",
+    "whatsapp": "WhatsApp"
   },
   "search": "Search...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "More",
     "auditLog": "Audit Log",
     "aiAssistant": "AI Assistant",
-    "tireHotel": "Tire hotel"
+    "tireHotel": "Tire hotel",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/en/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "Email",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "In-app note"
+      "in_app": "In-app note",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Repeat",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Schedule",
     "scheduled": "Message scheduled",
     "updated": "Scheduled message updated",
-    "saveError": "Could not save the message"
+    "saveError": "Could not save the message",
+    "whatsappNeedsTemplate": "This will be sent long after the customer's 24 hour window closes, and no WhatsApp text template is set up, so it would fail at send time. Set one up in Settings > Providers > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Schedule message",

+ 30 - 0
messages/en/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Tire hotel",
         "description": "Seasonal tire storage"
+      },
+      "providers": {
+        "title": "Providers",
+        "description": "Email, SMS, WhatsApp & Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Prep pricing",
     "prepHint": "What each kind of prep costs. Leave one blank and it is never billed, which is what a shop that includes washing in the storage fee wants. Priced ones are added to a quote or work order automatically.",
     "prepNotCharged": "not charged"
+  },
+  "providers": {
+    "tabs": {
+      "email": "Email",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Email Settings",
+        "description": "Configure SMTP email sending to deliver invoices, quotes, and notifications directly from your workshop."
+      },
+      "sms": {
+        "feature": "SMS Messaging",
+        "description": "Send and receive SMS messages with your customers directly from Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "WhatsApp Messaging",
+        "description": "Send and receive WhatsApp messages, including photos of parts, directly from Torqvoice."
+      },
+      "telegram": {
+        "feature": "Telegram Messaging",
+        "description": "Send and receive Telegram messages with your customers directly from Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/en/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Send and receive WhatsApp messages with your customers.",
+    "helpLink": "Set up {provider}",
+    "save": "Save WhatsApp settings",
+    "saved": "WhatsApp settings saved",
+    "saveError": "Could not save WhatsApp settings",
+    "enable": {
+      "label": "Enable WhatsApp",
+      "hint": "Turn on once your provider credentials are in place.",
+      "disabledInfo": "Switch WhatsApp on to set up your provider, number and template."
+    },
+    "provider": {
+      "label": "Provider",
+      "hint": "Who supplies your WhatsApp number. Switching keeps the credentials of the old one."
+    },
+    "from": {
+      "label": "Your WhatsApp number",
+      "hint": "The business number customers see, in international format."
+    },
+    "secret": {
+      "show": "Show",
+      "hide": "Hide",
+      "stored": "Saved and hidden. Type a new value to replace it."
+    },
+    "webhook": {
+      "label": "Webhook URL",
+      "description": "Paste this into your provider so incoming messages reach Torqvoice.",
+      "copy": "Copy webhook URL",
+      "copied": "Webhook URL copied",
+      "saveFirst": "Save these settings before your provider verifies the URL. It is checked against what is stored here, so an unsaved form fails the check."
+    },
+    "template": {
+      "title": "Message template",
+      "description": "Used when you write to a customer who has not messaged you recently.",
+      "windowExplainer": "WhatsApp only allows free text for 24 hours after the customer's last message. Outside that window your message has to go out as a template approved by your provider.",
+      "nameLabel": "Template name",
+      "nameHint": "The approved template's name, or its Content SID at providers that use one.",
+      "languageLabel": "Template language",
+      "languageHint": "The language code the template was approved under, for example de.",
+      "variablesLabel": "Template values, in order",
+      "variablesHint": "Pick the values that fill your template's numbered blanks, in the same order. Leave empty if your template has no blanks.",
+      "tokens": {
+        "customer": "customer name",
+        "vehicle": "year, make and model",
+        "plate": "registration",
+        "message": "what you type",
+        "workshop": "your shop name",
+        "photo": "the attached photo"
+      },
+      "variablesEmpty": "No values yet. Add them below, in the order your template expects.",
+      "variablesRemove": "Remove {name}",
+      "mediaUrlLabel": "Media URL for your template",
+      "mediaUrlHint": "Paste this into the template's media field and replace n with the number of the photo value.",
+      "mediaUrlCopy": "Copy media URL",
+      "mediaUrlCopied": "Media URL copied",
+      "mediaUrlSample": "For the provider's sample value, use: sample",
+      "textTitle": "Text messages",
+      "textDescription": "Used when you write with no photo attached: from Messages, from the WhatsApp tab on a customer, or from a scheduled message.",
+      "mediaTitle": "Messages with a photo",
+      "mediaDescription": "Used by the Send on WhatsApp button on a job's photos, and whenever you attach a photo in a conversation.",
+      "textMissing": "Not set up: you cannot write to a customer who has not messaged in the last 24 hours.",
+      "mediaMissing": "Not set up: you cannot send photos to a customer who has not messaged in the last 24 hours.",
+      "whatIsIt": "A template is a fixed message you write once and {provider} has approved: the wording never changes, only the numbered blanks you fill in. Write it in their console, then paste its identifier here.",
+      "whyTwo": "Two are needed because WhatsApp fixes a template's media type when it approves it. One approved with a photo can only ever carry a photo, and one approved as text can never carry one.",
+      "exampleLabel": "Example body to write in your provider's console",
+      "exampleTextValues": "Fill it with: customer, message. Write it in the language your customers read.",
+      "exampleMediaValues": "Fill it with: customer, message, and photo for the media URL. Write it in the language your customers read.",
+      "exampleMediaValuesHeader": "Fill it with: customer, message. The photo is attached automatically, so it needs no blank of its own. Write it in the language your customers read."
+    },
+    "test": {
+      "send": "Send test",
+      "sent": "Test message sent",
+      "failed": "Could not send the test message",
+      "hint": "Message yourself on WhatsApp first, otherwise the test needs an approved template."
+    },
+    "disconnect": {
+      "action": "Disconnect WhatsApp",
+      "hint": "Removes the credentials from Torqvoice. Your conversations are kept.",
+      "done": "WhatsApp disconnected",
+      "title": "Disconnect"
+    },
+    "savedIncomplete": "Saved. Still needed before you can send: {fields}",
+    "fieldStillNeeded": "Still needed before you can send",
+    "steps": {
+      "openProvider": "Open {provider}",
+      "openNumber": "Find your sender number",
+      "openToken": "Create a permanent token",
+      "consolePath": "In Meta, go to:",
+      "provider": {
+        "title": "Choose your provider and number",
+        "description": "Twilio is quicker to start with and handles Meta for you. Meta Cloud API costs less but you complete its business verification yourself. Enter the number customers will see, in international format."
+      },
+      "credentials": {
+        "title": "Enter your credentials",
+        "description": "Copy each value from {provider}. Every field below says exactly where to find it. Save when you have them; you can come back for the rest."
+      },
+      "webhook": {
+        "title": "Register the webhook",
+        "description": "Copy this address into {provider} so messages from customers reach Torqvoice. Save this page first: the address is checked against what is stored here.",
+        "open": "Open {provider} webhook settings",
+        "recheck": "Check again",
+        "waiting": "Nothing has reached this address yet. It turns green the moment your provider verifies it or delivers a message.",
+        "seen": "Your provider has reached this address. Incoming messages will appear in Messages."
+      },
+      "templates": {
+        "title": "Approve your templates",
+        "description": "Only needed to write first, outside the 24 hour window. You can skip this now and come back once messaging works.",
+        "open": "Open {provider} templates"
+      },
+      "test": {
+        "title": "Send a test message",
+        "description": "Message your WhatsApp number from your own phone first. That proves the webhook works and opens a 24 hour window, so a reply needs no template."
+      },
+      "back": "Back",
+      "continue": "Continue"
+    },
+    "register": {
+      "title": "Register the number with Meta",
+      "description": "Meta needs a separate registration once the number is verified. Choose a six-digit PIN, or use the number's existing two-step verification PIN if it has one. Write it down: you need it to move or re-register the number later.",
+      "action": "Register number",
+      "done": "Number registered",
+      "failed": "Could not register the number",
+      "limit": "Meta allows 10 registration attempts per number in 72 hours. A wrong PIN counts, so check it before pressing."
+    }
+  },
+  "messages": {
+    "featureName": "WhatsApp Messaging",
+    "featureDescription": "Send and receive WhatsApp messages, including photos of parts, directly from Torqvoice.",
+    "noThreads": "No conversations yet",
+    "noThreadsHint": "Messages appear here as soon as a customer writes to your WhatsApp number, or you write to them.",
+    "selectThread": "Pick a conversation",
+    "backToThreads": "Conversations",
+    "empty": "Nothing here yet.",
+    "placeholder": "Write a message",
+    "send": "Send",
+    "sendFailed": "Could not send the message",
+    "attach": "Attach a photo",
+    "attachFailed": "Could not attach the photo",
+    "removeAttachment": "Remove attachment",
+    "attachment": "Attachment",
+    "delete": "Delete",
+    "deleteFailed": "Could not delete the message",
+    "sentAsTemplate": "template",
+    "sentAsTemplateNotice": "Sent as your approved template, because the customer has not written in the last 24 hours.",
+    "windowClosed": "The customer has not written in the last 24 hours, so this will go out as your approved template.",
+    "unknownNumber": "This number belongs to no customer yet. Add them as a customer to reply.",
+    "notConfigured": {
+      "title": "WhatsApp is not connected",
+      "description": "Connect a WhatsApp number to send and receive messages with your customers.",
+      "action": "Set up WhatsApp"
+    },
+    "title": "Conversations",
+    "searchPlaceholder": "Search conversations",
+    "noSearchResults": "No conversations match your search.",
+    "compose": {
+      "newMessage": "New message",
+      "title": "New WhatsApp message",
+      "searchPlaceholder": "Search customers",
+      "noMatches": "No customers match your search.",
+      "noRecipients": "No customers with a phone number yet."
+    },
+    "windowBlocked": "This customer has not written in the last 24 hours, so WhatsApp will not deliver a message right now. Ask them to send you one, or set up an approved template.",
+    "windowBlockedAction": "Set up a template",
+    "windowBlockedPlaceholder": "Waiting for the customer to write first"
+  },
+  "sendPhoto": {
+    "action": "Send on WhatsApp",
+    "title": "Send photo on WhatsApp",
+    "description": "The customer gets this photo in their WhatsApp chat.",
+    "captionPlaceholder": "Add a note, for example what the photo shows",
+    "send": "Send",
+    "cancel": "Cancel",
+    "sent": "Photo sent",
+    "sentAsTemplate": "Photo sent as your approved template",
+    "failed": "Could not send the photo"
+  }
+}

+ 4 - 2
messages/es/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Juego almacenado de nuevo",
     "tire_set_dispose": "Juego de neumáticos dado de baja",
     "tire_set_attach": "Archivos añadidos a un juego de neumáticos",
-    "tire_set_detach": "Archivo quitado de un juego de neumáticos"
+    "tire_set_detach": "Archivo quitado de un juego de neumáticos",
+    "whatsapp_send": "Mensaje de WhatsApp enviado"
   },
   "summary": {
     "customField_create": "Campo personalizado «{name}» creado",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Vehículo {id} recuperado del archivo",
     "vehicle_update": "Vehículo {name} actualizado, cambios: {fields}",
     "vehicle_updateNoChanges": "Vehículo {name} actualizado, sin cambios",
-    "webhook_create": "Webhook {name} creado"
+    "webhook_create": "Webhook {name} creado",
+    "whatsapp_send": "Envió un mensaje de WhatsApp a {name}"
   }
 }

+ 2 - 1
messages/es/customers.json

@@ -38,7 +38,8 @@
       "messages": "Mensajes",
       "requests": "Solicitudes ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Placa",

+ 4 - 1
messages/es/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Envíe y reciba mensajes SMS con sus clientes directamente desde Torqvoice.",
     "tabs": {
       "inbox": "Bandeja",
-      "scheduled": "Programados"
+      "scheduled": "Programados",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS no está configurado",
     "smsNotConfiguredHint": "Conecta un proveedor de SMS para enviar y recibir mensajes aquí. El correo programado funciona igual.",

+ 26 - 0
messages/es/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Conversaciones",
+    "searchPlaceholder": "Buscar conversaciones",
+    "noSearchResults": "Ninguna conversación coincide con tu búsqueda.",
+    "empty": "Aún no hay conversaciones.",
+    "selectConversation": "Elige una conversación",
+    "back": "Atrás",
+    "newMessage": "Nuevo mensaje",
+    "unknownContact": "Este número aún no pertenece a ningún cliente. Añádelo como cliente para responder.",
+    "noChannelsTitle": "Sin canal de mensajería",
+    "noChannelsHint": "Conecta SMS, WhatsApp o Telegram para escribir a tus clientes desde aquí.",
+    "goToProviders": "Configurar un proveedor",
+    "chooseChannel": {
+      "title": "¿Por qué canal quieres enviar?",
+      "description": "Tienes más de una forma de llegar a tus clientes."
+    },
+    "deleteConversation": "Eliminar conversación",
+    "deleteTitle": "¿Eliminar esta conversación?",
+    "deleteDescription": "Elimina tu copia de la conversación con {name}. Los mensajes se quedan en su teléfono.",
+    "deleteFailed": "No se pudo eliminar la conversación",
+    "delete": "Eliminar",
+    "cancel": "Cancelar",
+    "loadMore": "Cargar más"
+  }
+}

+ 4 - 2
messages/es/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Todos los juegos",
     "tireStorage": "Distribución del almacén",
     "tireSetDetails": "Juego de neumáticos",
-    "tireForecast": "La próxima temporada"
+    "tireForecast": "La próxima temporada",
+    "whatsapp": "WhatsApp"
   },
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Más",
     "auditLog": "Registro de auditoría",
     "aiAssistant": "Asistente IA",
-    "tireHotel": "Hotel de neumáticos"
+    "tireHotel": "Hotel de neumáticos",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/es/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "Correo",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Nota en la app"
+      "in_app": "Nota en la app",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Repetir",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Programar",
     "scheduled": "Mensaje programado",
     "updated": "Mensaje programado actualizado",
-    "saveError": "No se pudo guardar el mensaje"
+    "saveError": "No se pudo guardar el mensaje",
+    "whatsappNeedsTemplate": "Se enviará mucho después de que se cierre la ventana de 24 horas del cliente y no hay plantilla de texto de WhatsApp configurada, así que fallaría al enviarse. Configura una en Ajustes > Proveedores > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Programar mensaje",

+ 30 - 0
messages/es/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Hotel de neumáticos",
         "description": "Almacenamiento estacional de neumáticos"
+      },
+      "providers": {
+        "title": "Proveedores",
+        "description": "Correo, SMS, WhatsApp y Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Precios de preparación",
     "prepHint": "Lo que cuesta cada tipo de preparación. Déjalo en blanco y no se factura nunca, que es lo que quiere un taller que incluye el lavado en el almacenaje. Las que tienen precio se añaden solas al presupuesto y a la orden.",
     "prepNotCharged": "no se cobra"
+  },
+  "providers": {
+    "tabs": {
+      "email": "Correo",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Ajustes de correo",
+        "description": "Configura el envío SMTP para entregar facturas, presupuestos y avisos desde tu taller."
+      },
+      "sms": {
+        "feature": "Mensajería SMS",
+        "description": "Envía y recibe SMS con tus clientes desde Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Mensajería WhatsApp",
+        "description": "Envía y recibe mensajes de WhatsApp, incluidas fotos de piezas, desde Torqvoice."
+      },
+      "telegram": {
+        "feature": "Mensajería Telegram",
+        "description": "Envía y recibe mensajes de Telegram con tus clientes desde Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/es/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Envía y recibe mensajes de WhatsApp con tus clientes.",
+    "helpLink": "Configurar {provider}",
+    "save": "Guardar ajustes de WhatsApp",
+    "saved": "Ajustes de WhatsApp guardados",
+    "saveError": "No se pudieron guardar los ajustes de WhatsApp",
+    "enable": {
+      "label": "Activar WhatsApp",
+      "hint": "Actívalo cuando las credenciales del proveedor estén puestas.",
+      "disabledInfo": "Activa WhatsApp para configurar tu proveedor, número y plantilla."
+    },
+    "provider": {
+      "label": "Proveedor",
+      "hint": "Quién te da el número de WhatsApp. Al cambiar se conservan las credenciales del anterior."
+    },
+    "from": {
+      "label": "Tu número de WhatsApp",
+      "hint": "El número que ven los clientes, en formato internacional."
+    },
+    "secret": {
+      "show": "Mostrar",
+      "hide": "Ocultar",
+      "stored": "Guardado y oculto. Escribe un valor nuevo para reemplazarlo."
+    },
+    "webhook": {
+      "label": "URL del webhook",
+      "description": "Pega esto en tu proveedor para que los mensajes entrantes lleguen a Torqvoice.",
+      "copy": "Copiar URL del webhook",
+      "copied": "URL del webhook copiada",
+      "saveFirst": "Guarda estos ajustes antes de que tu proveedor verifique la URL. Se comprueba contra lo guardado aquí, así que un formulario sin guardar falla."
+    },
+    "template": {
+      "title": "Plantilla de mensaje",
+      "description": "Se usa cuando escribes a un cliente que no te ha escrito hace poco.",
+      "windowExplainer": "WhatsApp solo permite texto libre durante 24 horas tras el último mensaje del cliente. Fuera de esa ventana, tu mensaje debe salir como plantilla aprobada por tu proveedor.",
+      "nameLabel": "Nombre de la plantilla",
+      "nameHint": "El nombre de la plantilla aprobada, o su Content SID en proveedores que lo usan.",
+      "languageLabel": "Idioma de la plantilla",
+      "languageHint": "El código de idioma con el que se aprobó, por ejemplo es.",
+      "variablesLabel": "Valores de la plantilla, en orden",
+      "variablesHint": "Elige los valores que rellenan los huecos numerados de tu plantilla, en el mismo orden. Déjalo vacío si tu plantilla no tiene huecos.",
+      "tokens": {
+        "customer": "nombre del cliente",
+        "vehicle": "año, marca y modelo",
+        "plate": "matrícula",
+        "message": "lo que escribas",
+        "workshop": "nombre de tu taller",
+        "photo": "la foto adjunta"
+      },
+      "variablesEmpty": "Aún sin valores. Añádelos abajo, en el orden que espera tu plantilla.",
+      "variablesRemove": "Quitar {name}",
+      "mediaUrlLabel": "URL de medios para tu plantilla",
+      "mediaUrlHint": "Pega esto en el campo de medios de la plantilla y sustituye n por el número del valor de la foto.",
+      "mediaUrlCopy": "Copiar URL de medios",
+      "mediaUrlCopied": "URL de medios copiada",
+      "mediaUrlSample": "Como valor de muestra en el proveedor, usa: sample",
+      "textTitle": "Mensajes de texto",
+      "textDescription": "Se usa cuando escribes sin adjuntar foto: desde Mensajes, desde la pestaña WhatsApp de un cliente o desde un mensaje programado.",
+      "mediaTitle": "Mensajes con foto",
+      "mediaDescription": "Lo usa el botón Enviar por WhatsApp en las fotos de un trabajo, y siempre que adjuntas una foto en una conversación.",
+      "textMissing": "Sin configurar: no puedes escribir a un cliente que no te ha escrito en las últimas 24 horas.",
+      "mediaMissing": "Sin configurar: no puedes enviar fotos a un cliente que no te ha escrito en las últimas 24 horas.",
+      "whatIsIt": "Una plantilla es un mensaje fijo que escribes una vez y que {provider} aprueba: el texto nunca cambia, solo rellenas los huecos numerados. Escríbela en su consola y pega aquí su identificador.",
+      "whyTwo": "Hacen falta dos porque WhatsApp fija el tipo de medio al aprobar la plantilla. Una aprobada con foto solo puede llevar fotos, y una aprobada como texto nunca podrá llevarlas.",
+      "exampleLabel": "Cuerpo de ejemplo para la consola de tu proveedor",
+      "exampleTextValues": "Rellénalo con: customer, message. Escríbelo en el idioma de tus clientes.",
+      "exampleMediaValues": "Rellénalo con: customer, message y photo para la URL de medios. Escríbelo en el idioma de tus clientes.",
+      "exampleMediaValuesHeader": "Rellénela con: customer, message. La foto se adjunta automáticamente, así que no necesita su propio hueco. Escríbala en el idioma que leen sus clientes."
+    },
+    "test": {
+      "send": "Enviar prueba",
+      "sent": "Mensaje de prueba enviado",
+      "failed": "No se pudo enviar el mensaje de prueba",
+      "hint": "Escríbete primero por WhatsApp; si no, la prueba necesita una plantilla aprobada."
+    },
+    "disconnect": {
+      "action": "Desconectar WhatsApp",
+      "hint": "Elimina las credenciales de Torqvoice. Se conservan tus conversaciones.",
+      "done": "WhatsApp desconectado",
+      "title": "Desconectar"
+    },
+    "savedIncomplete": "Guardado. Aún falta para poder enviar: {fields}",
+    "fieldStillNeeded": "Aún necesario para enviar",
+    "steps": {
+      "openProvider": "Abrir {provider}",
+      "openNumber": "Buscar su número de remitente",
+      "openToken": "Crear un token permanente",
+      "consolePath": "En Meta, vaya a:",
+      "provider": {
+        "title": "Elige proveedor y número",
+        "description": "Twilio se pone en marcha antes y gestiona Meta por ti. Meta Cloud API cuesta menos, pero completas tú su verificación de empresa. Escribe el número que verán los clientes, en formato internacional."
+      },
+      "credentials": {
+        "title": "Introduce tus credenciales",
+        "description": "Copia cada valor desde {provider}. Cada campo indica dónde encontrarlo. Guarda cuando los tengas; puedes volver a por el resto."
+      },
+      "webhook": {
+        "title": "Registra el webhook",
+        "description": "Copia esta dirección en {provider} para que los mensajes de clientes lleguen a Torqvoice. Guarda antes esta página: la dirección se comprueba contra lo guardado aquí.",
+        "open": "Abrir ajustes de webhook en {provider}",
+        "recheck": "Comprobar de nuevo",
+        "waiting": "Todavía no ha llegado nada a esta dirección. Se pondrá en verde en cuanto tu proveedor la verifique o entregue un mensaje.",
+        "seen": "Tu proveedor ha alcanzado esta dirección. Los mensajes entrantes aparecerán en Mensajes."
+      },
+      "templates": {
+        "title": "Aprueba tus plantillas",
+        "description": "Solo hace falta para escribir primero, fuera de la ventana de 24 horas. Puedes saltarlo ahora y volver cuando la mensajería funcione.",
+        "open": "Abrir plantillas de {provider}"
+      },
+      "test": {
+        "title": "Envía un mensaje de prueba",
+        "description": "Escribe primero a tu número de WhatsApp desde tu propio teléfono. Eso prueba el webhook y abre una ventana de 24 horas, así la respuesta no necesita plantilla."
+      },
+      "back": "Atrás",
+      "continue": "Continuar"
+    },
+    "register": {
+      "title": "Registra el número en Meta",
+      "description": "Meta requiere un registro aparte una vez verificado el número. Elige un PIN de seis dígitos, o usa el PIN de verificación en dos pasos si ya tiene uno. Anótalo: lo necesitarás para mover o volver a registrar el número.",
+      "action": "Registrar número",
+      "done": "Número registrado",
+      "failed": "No se pudo registrar el número",
+      "limit": "Meta permite 10 intentos de registro por número en 72 horas. Un PIN incorrecto cuenta, así que compruébalo antes."
+    }
+  },
+  "messages": {
+    "featureName": "Mensajería WhatsApp",
+    "featureDescription": "Envía y recibe mensajes de WhatsApp, incluidas fotos de piezas, desde Torqvoice.",
+    "noThreads": "Aún no hay conversaciones",
+    "noThreadsHint": "Los mensajes aparecen aquí en cuanto un cliente escribe a tu número de WhatsApp, o tú le escribes.",
+    "selectThread": "Elige una conversación",
+    "backToThreads": "Conversaciones",
+    "empty": "Aquí todavía no hay nada.",
+    "placeholder": "Escribe un mensaje",
+    "send": "Enviar",
+    "sendFailed": "No se pudo enviar el mensaje",
+    "attach": "Adjuntar una foto",
+    "attachFailed": "No se pudo adjuntar la foto",
+    "removeAttachment": "Quitar adjunto",
+    "attachment": "Adjunto",
+    "delete": "Eliminar",
+    "deleteFailed": "No se pudo eliminar el mensaje",
+    "sentAsTemplate": "plantilla",
+    "sentAsTemplateNotice": "Enviado como tu plantilla aprobada, porque el cliente no ha escrito en las últimas 24 horas.",
+    "windowClosed": "El cliente no ha escrito en las últimas 24 horas, así que esto saldrá como tu plantilla aprobada.",
+    "unknownNumber": "Este número aún no pertenece a ningún cliente. Añádelo como cliente para responder.",
+    "notConfigured": {
+      "title": "WhatsApp no está conectado",
+      "description": "Conecta un número de WhatsApp para enviar y recibir mensajes con tus clientes.",
+      "action": "Configurar WhatsApp"
+    },
+    "title": "Conversaciones",
+    "searchPlaceholder": "Buscar conversaciones",
+    "noSearchResults": "Ninguna conversación coincide con tu búsqueda.",
+    "compose": {
+      "newMessage": "Nuevo mensaje",
+      "title": "Nuevo mensaje de WhatsApp",
+      "searchPlaceholder": "Buscar clientes",
+      "noMatches": "Ningún cliente coincide con tu búsqueda.",
+      "noRecipients": "Aún no hay clientes con teléfono."
+    },
+    "windowBlocked": "Este cliente no ha escrito en las últimas 24 horas, así que WhatsApp no entregará el mensaje ahora. Pídele que te escriba o configura una plantilla aprobada.",
+    "windowBlockedAction": "Configurar una plantilla",
+    "windowBlockedPlaceholder": "Esperando a que el cliente escriba primero"
+  },
+  "sendPhoto": {
+    "action": "Enviar por WhatsApp",
+    "title": "Enviar foto por WhatsApp",
+    "description": "El cliente recibe esta foto en su chat de WhatsApp.",
+    "captionPlaceholder": "Añade una nota, por ejemplo qué muestra la foto",
+    "send": "Enviar",
+    "cancel": "Cancelar",
+    "sent": "Foto enviada",
+    "sentAsTemplate": "Foto enviada como tu plantilla aprobada",
+    "failed": "No se pudo enviar la foto"
+  }
+}

+ 4 - 2
messages/fr/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Jeu de nouveau stocké",
     "tire_set_dispose": "Jeu de pneus mis au rebut",
     "tire_set_attach": "Fichiers ajoutés à un jeu de pneus",
-    "tire_set_detach": "Fichier retiré d’un jeu de pneus"
+    "tire_set_detach": "Fichier retiré d’un jeu de pneus",
+    "whatsapp_send": "Message WhatsApp envoyé"
   },
   "summary": {
     "customField_create": "Champ personnalisé « {name} » créé",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Véhicule {id} sorti des archives",
     "vehicle_update": "Véhicule {name} mis à jour, modifié : {fields}",
     "vehicle_updateNoChanges": "Véhicule {name} mis à jour, aucun changement",
-    "webhook_create": "Webhook {name} créé"
+    "webhook_create": "Webhook {name} créé",
+    "whatsapp_send": "Message WhatsApp envoyé à {name}"
   }
 }

+ 2 - 1
messages/fr/customers.json

@@ -38,7 +38,8 @@
       "messages": "Messages",
       "requests": "Demandes ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Plaque",

+ 4 - 1
messages/fr/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Envoyez et recevez des SMS avec vos clients directement depuis Torqvoice.",
     "tabs": {
       "inbox": "Boîte de réception",
-      "scheduled": "Programmés"
+      "scheduled": "Programmés",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "Le SMS n'est pas configuré",
     "smsNotConfiguredHint": "Connectez un fournisseur SMS pour envoyer et recevoir des messages ici. Les e-mails programmés fonctionnent sans cela.",

+ 26 - 0
messages/fr/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Conversations",
+    "searchPlaceholder": "Rechercher des conversations",
+    "noSearchResults": "Aucune conversation ne correspond à votre recherche.",
+    "empty": "Aucune conversation pour l'instant.",
+    "selectConversation": "Choisissez une conversation",
+    "back": "Retour",
+    "newMessage": "Nouveau message",
+    "unknownContact": "Ce numéro n'appartient encore à aucun client. Ajoutez-le comme client pour répondre.",
+    "noChannelsTitle": "Aucun canal de messagerie configuré",
+    "noChannelsHint": "Connectez SMS, WhatsApp ou Telegram pour écrire à vos clients d'ici.",
+    "goToProviders": "Configurer un fournisseur",
+    "chooseChannel": {
+      "title": "Envoyer sur quel canal ?",
+      "description": "Vous avez plusieurs façons de joindre vos clients."
+    },
+    "deleteConversation": "Supprimer la conversation",
+    "deleteTitle": "Supprimer cette conversation ?",
+    "deleteDescription": "Supprime votre copie de la conversation avec {name}. Les messages restent sur leur téléphone.",
+    "deleteFailed": "Impossible de supprimer la conversation",
+    "delete": "Supprimer",
+    "cancel": "Annuler",
+    "loadMore": "Charger plus"
+  }
+}

+ 4 - 2
messages/fr/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Tous les jeux de pneus",
     "tireStorage": "Organisation du stockage",
     "tireSetDetails": "Jeu de pneus",
-    "tireForecast": "La saison prochaine"
+    "tireForecast": "La saison prochaine",
+    "whatsapp": "WhatsApp"
   },
   "search": "Rechercher…",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Plus",
     "auditLog": "Journal d'audit",
     "aiAssistant": "Assistant IA",
-    "tireHotel": "Hôtel à pneus"
+    "tireHotel": "Hôtel à pneus",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/fr/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-mail",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Note dans l'app"
+      "in_app": "Note dans l'app",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Répéter",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Programmer",
     "scheduled": "Message programmé",
     "updated": "Message programmé mis à jour",
-    "saveError": "Impossible d'enregistrer le message"
+    "saveError": "Impossible d'enregistrer le message",
+    "whatsappNeedsTemplate": "Ce message partira bien après la fermeture de la fenêtre de 24 heures et aucun modèle texte WhatsApp n'est configuré : il échouerait à l'envoi. Configurez-en un dans Paramètres > Fournisseurs > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Programmer un message",

+ 30 - 0
messages/fr/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Hôtel à pneus",
         "description": "Stockage saisonnier des pneus"
+      },
+      "providers": {
+        "title": "Fournisseurs",
+        "description": "E-mail, SMS, WhatsApp et Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Prix de la préparation",
     "prepHint": "Ce que coûte chaque type de préparation. Laissez vide et rien n’est facturé, ce qui convient aux ateliers qui incluent le lavage dans le stockage. Les prestations chiffrées sont ajoutées automatiquement au devis et à l’ordre.",
     "prepNotCharged": "non facturé"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Paramètres e-mail",
+        "description": "Configurez l'envoi SMTP pour transmettre factures, devis et notifications depuis votre atelier."
+      },
+      "sms": {
+        "feature": "Messagerie SMS",
+        "description": "Envoyez et recevez des SMS avec vos clients depuis Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Messagerie WhatsApp",
+        "description": "Envoyez et recevez des messages WhatsApp, photos de pièces comprises, depuis Torqvoice."
+      },
+      "telegram": {
+        "feature": "Messagerie Telegram",
+        "description": "Envoyez et recevez des messages Telegram avec vos clients depuis Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/fr/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Envoyez et recevez des messages WhatsApp avec vos clients.",
+    "helpLink": "Configurer {provider}",
+    "save": "Enregistrer les paramètres WhatsApp",
+    "saved": "Paramètres WhatsApp enregistrés",
+    "saveError": "Impossible d'enregistrer les paramètres WhatsApp",
+    "enable": {
+      "label": "Activer WhatsApp",
+      "hint": "Activez une fois les identifiants du fournisseur renseignés.",
+      "disabledInfo": "Activez WhatsApp pour configurer votre fournisseur, votre numéro et votre modèle."
+    },
+    "provider": {
+      "label": "Fournisseur",
+      "hint": "Qui fournit votre numéro WhatsApp. Un changement conserve les identifiants du précédent."
+    },
+    "from": {
+      "label": "Votre numéro WhatsApp",
+      "hint": "Le numéro que voient les clients, au format international."
+    },
+    "secret": {
+      "show": "Afficher",
+      "hide": "Masquer",
+      "stored": "Enregistré et masqué. Saisissez une nouvelle valeur pour le remplacer."
+    },
+    "webhook": {
+      "label": "URL du webhook",
+      "description": "Collez-la chez votre fournisseur pour que les messages entrants arrivent dans Torqvoice.",
+      "copy": "Copier l'URL du webhook",
+      "copied": "URL du webhook copiée",
+      "saveFirst": "Enregistrez ces paramètres avant que votre fournisseur ne vérifie l'URL. La vérification porte sur ce qui est enregistré ici : un formulaire non enregistré échoue."
+    },
+    "template": {
+      "title": "Modèle de message",
+      "description": "Utilisé quand vous écrivez à un client qui ne vous a pas écrit récemment.",
+      "windowExplainer": "WhatsApp n'autorise le texte libre que pendant 24 heures après le dernier message du client. En dehors, votre message doit partir sous forme de modèle approuvé par votre fournisseur.",
+      "nameLabel": "Nom du modèle",
+      "nameHint": "Le nom du modèle approuvé, ou son Content SID chez les fournisseurs qui en utilisent un.",
+      "languageLabel": "Langue du modèle",
+      "languageHint": "Le code de langue sous lequel le modèle a été approuvé, par exemple fr.",
+      "variablesLabel": "Valeurs du modèle, dans l'ordre",
+      "variablesHint": "Choisissez les valeurs qui remplissent les emplacements numérotés de votre modèle, dans le même ordre. Laissez vide si votre modèle n'en a pas.",
+      "tokens": {
+        "customer": "nom du client",
+        "vehicle": "année, marque et modèle",
+        "plate": "immatriculation",
+        "message": "ce que vous écrivez",
+        "workshop": "nom de votre atelier",
+        "photo": "la photo jointe"
+      },
+      "variablesEmpty": "Aucune valeur pour l'instant. Ajoutez-les ci-dessous, dans l'ordre attendu par votre modèle.",
+      "variablesRemove": "Retirer {name}",
+      "mediaUrlLabel": "URL du média pour votre modèle",
+      "mediaUrlHint": "Collez ceci dans le champ média du modèle et remplacez n par le numéro de la valeur photo.",
+      "mediaUrlCopy": "Copier l'URL du média",
+      "mediaUrlCopied": "URL du média copiée",
+      "mediaUrlSample": "Comme valeur d'exemple chez le fournisseur, utilisez : sample",
+      "textTitle": "Messages texte",
+      "textDescription": "Utilisé quand vous écrivez sans photo : depuis Messages, depuis l'onglet WhatsApp d'un client ou depuis un message programmé.",
+      "mediaTitle": "Messages avec photo",
+      "mediaDescription": "Utilisé par le bouton Envoyer sur WhatsApp sur les photos d'une intervention, et dès que vous joignez une photo dans une conversation.",
+      "textMissing": "Non configuré : vous ne pouvez pas écrire à un client qui n'a pas écrit depuis 24 heures.",
+      "mediaMissing": "Non configuré : vous ne pouvez pas envoyer de photos à un client qui n'a pas écrit depuis 24 heures.",
+      "whatIsIt": "Un modèle est un message figé que vous écrivez une fois et que {provider} approuve : le texte ne change jamais, vous ne remplissez que les emplacements numérotés. Écrivez-le dans leur console, puis collez son identifiant ici.",
+      "whyTwo": "Il en faut deux car WhatsApp fige le type de média à l'approbation. Un modèle approuvé avec une photo ne peut porter que des photos, et un approuvé comme texte n'en portera jamais.",
+      "exampleLabel": "Exemple de corps à écrire dans la console de votre fournisseur",
+      "exampleTextValues": "À remplir avec : customer, message. Rédigez-le dans la langue de vos clients.",
+      "exampleMediaValues": "À remplir avec : customer, message et photo pour l'URL du média. Rédigez-le dans la langue de vos clients.",
+      "exampleMediaValuesHeader": "Remplissez-la avec : customer, message. La photo est jointe automatiquement, elle n'a donc pas besoin de son propre espace. Rédigez-la dans la langue de vos clients."
+    },
+    "test": {
+      "send": "Envoyer un test",
+      "sent": "Message de test envoyé",
+      "failed": "Impossible d'envoyer le message de test",
+      "hint": "Écrivez-vous d'abord sur WhatsApp, sinon le test exige un modèle approuvé."
+    },
+    "disconnect": {
+      "action": "Déconnecter WhatsApp",
+      "hint": "Supprime les identifiants de Torqvoice. Vos conversations sont conservées.",
+      "done": "WhatsApp déconnecté",
+      "title": "Déconnecter"
+    },
+    "savedIncomplete": "Enregistré. Il manque encore pour envoyer : {fields}",
+    "fieldStillNeeded": "Encore nécessaire pour envoyer",
+    "steps": {
+      "openProvider": "Ouvrir {provider}",
+      "openNumber": "Trouver votre numéro d'expéditeur",
+      "openToken": "Créer un jeton permanent",
+      "consolePath": "Dans Meta, allez à :",
+      "provider": {
+        "title": "Choisissez votre fournisseur et votre numéro",
+        "description": "Twilio démarre plus vite et gère Meta pour vous. L'API Cloud de Meta coûte moins cher, mais vous passez sa vérification d'entreprise vous-même. Saisissez le numéro que verront les clients, au format international."
+      },
+      "credentials": {
+        "title": "Saisissez vos identifiants",
+        "description": "Copiez chaque valeur depuis {provider}. Chaque champ indique où la trouver. Enregistrez dès que vous les avez ; vous pourrez revenir pour le reste."
+      },
+      "webhook": {
+        "title": "Enregistrez le webhook",
+        "description": "Copiez cette adresse dans {provider} pour que les messages des clients parviennent à Torqvoice. Enregistrez d'abord cette page : l'adresse est vérifiée par rapport à ce qui est enregistré ici.",
+        "open": "Ouvrir les réglages webhook de {provider}",
+        "recheck": "Vérifier à nouveau",
+        "waiting": "Rien n'est encore arrivé à cette adresse. Elle passe au vert dès que votre fournisseur la vérifie ou livre un message.",
+        "seen": "Votre fournisseur a atteint cette adresse. Les messages entrants apparaîtront dans Messages."
+      },
+      "templates": {
+        "title": "Faites approuver vos modèles",
+        "description": "Nécessaire seulement pour écrire en premier, hors de la fenêtre de 24 heures. Vous pouvez passer et revenir une fois la messagerie en place.",
+        "open": "Ouvrir les modèles {provider}"
+      },
+      "test": {
+        "title": "Envoyez un message de test",
+        "description": "Écrivez d'abord à votre numéro WhatsApp depuis votre propre téléphone. Cela prouve que le webhook fonctionne et ouvre une fenêtre de 24 heures, la réponse n'a donc pas besoin de modèle."
+      },
+      "back": "Retour",
+      "continue": "Continuer"
+    },
+    "register": {
+      "title": "Enregistrer le numéro auprès de Meta",
+      "description": "Meta exige un enregistrement distinct une fois le numéro vérifié. Choisissez un code à six chiffres, ou reprenez le code de vérification en deux étapes s'il en a un. Notez-le : il servira pour déplacer ou réenregistrer le numéro.",
+      "action": "Enregistrer le numéro",
+      "done": "Numéro enregistré",
+      "failed": "Impossible d'enregistrer le numéro",
+      "limit": "Meta autorise 10 tentatives d'enregistrement par numéro sur 72 heures. Un code erroné compte, vérifiez-le avant."
+    }
+  },
+  "messages": {
+    "featureName": "Messagerie WhatsApp",
+    "featureDescription": "Envoyez et recevez des messages WhatsApp, photos de pièces comprises, depuis Torqvoice.",
+    "noThreads": "Aucune conversation pour l'instant",
+    "noThreadsHint": "Les messages apparaissent ici dès qu'un client écrit à votre numéro WhatsApp, ou que vous lui écrivez.",
+    "selectThread": "Choisissez une conversation",
+    "backToThreads": "Conversations",
+    "empty": "Rien pour l'instant.",
+    "placeholder": "Écrire un message",
+    "send": "Envoyer",
+    "sendFailed": "Impossible d'envoyer le message",
+    "attach": "Joindre une photo",
+    "attachFailed": "Impossible de joindre la photo",
+    "removeAttachment": "Retirer la pièce jointe",
+    "attachment": "Pièce jointe",
+    "delete": "Supprimer",
+    "deleteFailed": "Impossible de supprimer le message",
+    "sentAsTemplate": "modèle",
+    "sentAsTemplateNotice": "Envoyé sous forme de modèle approuvé, car le client n'a pas écrit depuis 24 heures.",
+    "windowClosed": "Le client n'a pas écrit depuis 24 heures, ce message partira donc sous forme de modèle approuvé.",
+    "unknownNumber": "Ce numéro n'appartient encore à aucun client. Ajoutez-le comme client pour répondre.",
+    "notConfigured": {
+      "title": "WhatsApp n'est pas connecté",
+      "description": "Connectez un numéro WhatsApp pour échanger des messages avec vos clients.",
+      "action": "Configurer WhatsApp"
+    },
+    "title": "Conversations",
+    "searchPlaceholder": "Rechercher des conversations",
+    "noSearchResults": "Aucune conversation ne correspond à votre recherche.",
+    "compose": {
+      "newMessage": "Nouveau message",
+      "title": "Nouveau message WhatsApp",
+      "searchPlaceholder": "Rechercher des clients",
+      "noMatches": "Aucun client ne correspond à votre recherche.",
+      "noRecipients": "Aucun client avec un numéro pour l'instant."
+    },
+    "windowBlocked": "Ce client n'a pas écrit depuis 24 heures, WhatsApp ne délivrera donc aucun message maintenant. Demandez-lui de vous écrire, ou configurez un modèle approuvé.",
+    "windowBlockedAction": "Configurer un modèle",
+    "windowBlockedPlaceholder": "En attente d'un message du client"
+  },
+  "sendPhoto": {
+    "action": "Envoyer sur WhatsApp",
+    "title": "Envoyer la photo sur WhatsApp",
+    "description": "Le client reçoit cette photo dans sa discussion WhatsApp.",
+    "captionPlaceholder": "Ajoutez une note, par exemple ce que montre la photo",
+    "send": "Envoyer",
+    "cancel": "Annuler",
+    "sent": "Photo envoyée",
+    "sentAsTemplate": "Photo envoyée sous forme de modèle approuvé",
+    "failed": "Impossible d'envoyer la photo"
+  }
+}

+ 4 - 2
messages/it/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Treno di nuovo in deposito",
     "tire_set_dispose": "Treno di pneumatici dismesso",
     "tire_set_attach": "File aggiunti a un treno di pneumatici",
-    "tire_set_detach": "File rimosso da un treno di pneumatici"
+    "tire_set_detach": "File rimosso da un treno di pneumatici",
+    "whatsapp_send": "Messaggio WhatsApp inviato"
   },
   "summary": {
     "customField_create": "Campo personalizzato «{name}» creato",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Veicolo {id} tolto dall'archivio",
     "vehicle_update": "Veicolo {name} aggiornato, modifiche: {fields}",
     "vehicle_updateNoChanges": "Veicolo {name} aggiornato, nessuna modifica",
-    "webhook_create": "Webhook {name} creato"
+    "webhook_create": "Webhook {name} creato",
+    "whatsapp_send": "Inviato un messaggio WhatsApp a {name}"
   }
 }

+ 2 - 1
messages/it/customers.json

@@ -38,7 +38,8 @@
       "messages": "Messaggi",
       "requests": "Richieste ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Targa",

+ 4 - 1
messages/it/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Invia e ricevi messaggi SMS con i tuoi clienti direttamente da Torqvoice.",
     "tabs": {
       "inbox": "In arrivo",
-      "scheduled": "Pianificati"
+      "scheduled": "Pianificati",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "L'SMS non è configurato",
     "smsNotConfiguredHint": "Collega un provider SMS per inviare e ricevere messaggi qui. Le email pianificate funzionano comunque.",

+ 26 - 0
messages/it/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Conversazioni",
+    "searchPlaceholder": "Cerca conversazioni",
+    "noSearchResults": "Nessuna conversazione corrisponde alla ricerca.",
+    "empty": "Ancora nessuna conversazione.",
+    "selectConversation": "Scegli una conversazione",
+    "back": "Indietro",
+    "newMessage": "Nuovo messaggio",
+    "unknownContact": "Questo numero non appartiene ancora a nessun cliente. Aggiungilo come cliente per rispondere.",
+    "noChannelsTitle": "Nessun canale di messaggistica",
+    "noChannelsHint": "Collega SMS, WhatsApp o Telegram per scrivere ai clienti da qui.",
+    "goToProviders": "Configura un provider",
+    "chooseChannel": {
+      "title": "Su quale canale inviare?",
+      "description": "Hai più di un modo per raggiungere i clienti."
+    },
+    "deleteConversation": "Elimina conversazione",
+    "deleteTitle": "Eliminare questa conversazione?",
+    "deleteDescription": "Rimuove la tua copia della conversazione con {name}. Sul telefono del cliente resta tutto.",
+    "deleteFailed": "Impossibile eliminare la conversazione",
+    "delete": "Elimina",
+    "cancel": "Annulla",
+    "loadMore": "Carica altro"
+  }
+}

+ 4 - 2
messages/it/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Tutti i treni",
     "tireStorage": "Disposizione del deposito",
     "tireSetDetails": "Treno di pneumatici",
-    "tireForecast": "La prossima stagione"
+    "tireForecast": "La prossima stagione",
+    "whatsapp": "WhatsApp"
   },
   "search": "Cerca...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Altro",
     "auditLog": "Registro attività",
     "aiAssistant": "Assistente IA",
-    "tireHotel": "Hotel pneumatici"
+    "tireHotel": "Hotel pneumatici",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/it/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "Email",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Nota nell'app"
+      "in_app": "Nota nell'app",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Ripeti",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Pianifica",
     "scheduled": "Messaggio pianificato",
     "updated": "Messaggio pianificato aggiornato",
-    "saveError": "Impossibile salvare il messaggio"
+    "saveError": "Impossibile salvare il messaggio",
+    "whatsappNeedsTemplate": "Partirà molto dopo la chiusura della finestra di 24 ore e non c'è un modello di testo WhatsApp configurato, quindi fallirebbe all'invio. Configuralo in Impostazioni > Provider > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Pianifica messaggio",

+ 30 - 0
messages/it/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Hotel pneumatici",
         "description": "Deposito stagionale degli pneumatici"
+      },
+      "providers": {
+        "title": "Provider",
+        "description": "Email, SMS, WhatsApp e Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Prezzi della preparazione",
     "prepHint": "Quanto costa ogni tipo di preparazione. Lasciandolo vuoto non viene mai fatturata, come serve a chi include il lavaggio nel deposito. Quelle con un prezzo finiscono da sole su preventivo e ordine.",
     "prepNotCharged": "non addebitato"
+  },
+  "providers": {
+    "tabs": {
+      "email": "Email",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Impostazioni email",
+        "description": "Configura l'invio SMTP per recapitare fatture, preventivi e notifiche dalla tua officina."
+      },
+      "sms": {
+        "feature": "Messaggi SMS",
+        "description": "Invia e ricevi SMS con i tuoi clienti da Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Messaggi WhatsApp",
+        "description": "Invia e ricevi messaggi WhatsApp, foto dei ricambi comprese, da Torqvoice."
+      },
+      "telegram": {
+        "feature": "Messaggi Telegram",
+        "description": "Invia e ricevi messaggi Telegram con i tuoi clienti da Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/it/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Invia e ricevi messaggi WhatsApp con i tuoi clienti.",
+    "helpLink": "Configura {provider}",
+    "save": "Salva impostazioni WhatsApp",
+    "saved": "Impostazioni WhatsApp salvate",
+    "saveError": "Impossibile salvare le impostazioni WhatsApp",
+    "enable": {
+      "label": "Attiva WhatsApp",
+      "hint": "Attiva quando le credenziali del provider sono inserite.",
+      "disabledInfo": "Attiva WhatsApp per configurare provider, numero e modello."
+    },
+    "provider": {
+      "label": "Provider",
+      "hint": "Chi fornisce il tuo numero WhatsApp. Cambiando restano le credenziali del precedente."
+    },
+    "from": {
+      "label": "Il tuo numero WhatsApp",
+      "hint": "Il numero che vedono i clienti, in formato internazionale."
+    },
+    "secret": {
+      "show": "Mostra",
+      "hide": "Nascondi",
+      "stored": "Salvato e nascosto. Scrivi un nuovo valore per sostituirlo."
+    },
+    "webhook": {
+      "label": "URL webhook",
+      "description": "Incollalo nel tuo provider così i messaggi in arrivo raggiungono Torqvoice.",
+      "copy": "Copia URL webhook",
+      "copied": "URL webhook copiato",
+      "saveFirst": "Salva queste impostazioni prima che il provider verifichi l'URL. Il controllo avviene su ciò che è salvato qui, quindi un modulo non salvato fallisce."
+    },
+    "template": {
+      "title": "Modello di messaggio",
+      "description": "Usato quando scrivi a un cliente che non ti ha scritto di recente.",
+      "windowExplainer": "WhatsApp consente testo libero solo per 24 ore dall'ultimo messaggio del cliente. Fuori da quella finestra il messaggio deve partire come modello approvato dal provider.",
+      "nameLabel": "Nome del modello",
+      "nameHint": "Il nome del modello approvato, o il Content SID nei provider che lo usano.",
+      "languageLabel": "Lingua del modello",
+      "languageHint": "Il codice lingua con cui è stato approvato, per esempio it.",
+      "variablesLabel": "Valori del modello, in ordine",
+      "variablesHint": "Scegli i valori che riempiono gli spazi numerati del modello, nello stesso ordine. Lascia vuoto se il modello non ha spazi.",
+      "tokens": {
+        "customer": "nome del cliente",
+        "vehicle": "anno, marca e modello",
+        "plate": "targa",
+        "message": "ciò che scrivi",
+        "workshop": "nome della tua officina",
+        "photo": "la foto allegata"
+      },
+      "variablesEmpty": "Ancora nessun valore. Aggiungili sotto, nell'ordine che il modello si aspetta.",
+      "variablesRemove": "Rimuovi {name}",
+      "mediaUrlLabel": "URL del media per il tuo modello",
+      "mediaUrlHint": "Incolla questo nel campo media del modello e sostituisci n con il numero del valore foto.",
+      "mediaUrlCopy": "Copia URL del media",
+      "mediaUrlCopied": "URL del media copiato",
+      "mediaUrlSample": "Come valore di esempio presso il provider, usa: sample",
+      "textTitle": "Messaggi di testo",
+      "textDescription": "Usato quando scrivi senza foto: da Messaggi, dalla scheda WhatsApp di un cliente o da un messaggio programmato.",
+      "mediaTitle": "Messaggi con foto",
+      "mediaDescription": "Usato dal pulsante Invia su WhatsApp sulle foto di un intervento, e ogni volta che alleghi una foto in una conversazione.",
+      "textMissing": "Non configurato: non puoi scrivere a un cliente che non ti scrive da 24 ore.",
+      "mediaMissing": "Non configurato: non puoi inviare foto a un cliente che non ti scrive da 24 ore.",
+      "whatIsIt": "Un modello è un messaggio fisso che scrivi una volta e che {provider} approva: il testo non cambia mai, riempi solo gli spazi numerati. Scrivilo nella loro console e incolla qui il suo identificatore.",
+      "whyTwo": "Ne servono due perché WhatsApp fissa il tipo di media all'approvazione. Uno approvato con foto può portare solo foto, uno approvato come testo non ne porterà mai.",
+      "exampleLabel": "Corpo di esempio da scrivere nella console del provider",
+      "exampleTextValues": "Riempilo con: customer, message. Scrivilo nella lingua dei tuoi clienti.",
+      "exampleMediaValues": "Riempilo con: customer, message e photo per l'URL del media. Scrivilo nella lingua dei tuoi clienti.",
+      "exampleMediaValuesHeader": "La riempia con: customer, message. La foto viene allegata automaticamente, quindi non serve uno spazio dedicato. La scriva nella lingua dei suoi clienti."
+    },
+    "test": {
+      "send": "Invia prova",
+      "sent": "Messaggio di prova inviato",
+      "failed": "Impossibile inviare il messaggio di prova",
+      "hint": "Scriviti prima su WhatsApp, altrimenti la prova richiede un modello approvato."
+    },
+    "disconnect": {
+      "action": "Disconnetti WhatsApp",
+      "hint": "Rimuove le credenziali da Torqvoice. Le conversazioni restano.",
+      "done": "WhatsApp disconnesso",
+      "title": "Disconnetti"
+    },
+    "savedIncomplete": "Salvato. Per inviare manca ancora: {fields}",
+    "fieldStillNeeded": "Ancora necessario per inviare",
+    "steps": {
+      "openProvider": "Apri {provider}",
+      "openNumber": "Trova il tuo numero mittente",
+      "openToken": "Crea un token permanente",
+      "consolePath": "In Meta, vada a:",
+      "provider": {
+        "title": "Scegli provider e numero",
+        "description": "Twilio parte prima e gestisce Meta per te. Meta Cloud API costa meno, ma la verifica aziendale la completi tu. Inserisci il numero che vedranno i clienti, in formato internazionale."
+      },
+      "credentials": {
+        "title": "Inserisci le credenziali",
+        "description": "Copia ogni valore da {provider}. Ogni campo dice dove trovarlo. Salva quando li hai; puoi tornare per il resto."
+      },
+      "webhook": {
+        "title": "Registra il webhook",
+        "description": "Copia questo indirizzo in {provider} così i messaggi dei clienti arrivano a Torqvoice. Salva prima questa pagina: l'indirizzo viene confrontato con quanto è salvato qui.",
+        "open": "Apri le impostazioni webhook di {provider}",
+        "recheck": "Controlla di nuovo",
+        "waiting": "A questo indirizzo non è ancora arrivato nulla. Diventa verde appena il provider lo verifica o consegna un messaggio.",
+        "seen": "Il tuo provider ha raggiunto questo indirizzo. I messaggi in arrivo compariranno in Messaggi."
+      },
+      "templates": {
+        "title": "Fai approvare i modelli",
+        "description": "Serve solo per scrivere per primo, fuori dalla finestra di 24 ore. Puoi saltarlo e tornarci quando la messaggistica funziona.",
+        "open": "Apri i modelli {provider}"
+      },
+      "test": {
+        "title": "Invia un messaggio di prova",
+        "description": "Scrivi prima al tuo numero WhatsApp dal tuo telefono. Questo dimostra che il webhook funziona e apre una finestra di 24 ore, così la risposta non richiede modelli."
+      },
+      "back": "Indietro",
+      "continue": "Continua"
+    },
+    "register": {
+      "title": "Registra il numero presso Meta",
+      "description": "Meta richiede una registrazione a parte una volta verificato il numero. Scegli un PIN di sei cifre, oppure usa il PIN di verifica in due passaggi se già presente. Annotalo: ti servirà per spostare o registrare di nuovo il numero.",
+      "action": "Registra numero",
+      "done": "Numero registrato",
+      "failed": "Impossibile registrare il numero",
+      "limit": "Meta consente 10 tentativi di registrazione per numero in 72 ore. Un PIN sbagliato conta, quindi controllalo prima."
+    }
+  },
+  "messages": {
+    "featureName": "Messaggi WhatsApp",
+    "featureDescription": "Invia e ricevi messaggi WhatsApp, foto dei ricambi comprese, da Torqvoice.",
+    "noThreads": "Ancora nessuna conversazione",
+    "noThreadsHint": "I messaggi compaiono qui appena un cliente scrive al tuo numero WhatsApp, o tu scrivi a lui.",
+    "selectThread": "Scegli una conversazione",
+    "backToThreads": "Conversazioni",
+    "empty": "Qui non c'è ancora nulla.",
+    "placeholder": "Scrivi un messaggio",
+    "send": "Invia",
+    "sendFailed": "Impossibile inviare il messaggio",
+    "attach": "Allega una foto",
+    "attachFailed": "Impossibile allegare la foto",
+    "removeAttachment": "Rimuovi allegato",
+    "attachment": "Allegato",
+    "delete": "Elimina",
+    "deleteFailed": "Impossibile eliminare il messaggio",
+    "sentAsTemplate": "modello",
+    "sentAsTemplateNotice": "Inviato come modello approvato, perché il cliente non scrive da oltre 24 ore.",
+    "windowClosed": "Il cliente non scrive da oltre 24 ore, quindi questo partirà come modello approvato.",
+    "unknownNumber": "Questo numero non appartiene ancora a nessun cliente. Aggiungilo come cliente per rispondere.",
+    "notConfigured": {
+      "title": "WhatsApp non è collegato",
+      "description": "Collega un numero WhatsApp per scambiare messaggi con i tuoi clienti.",
+      "action": "Configura WhatsApp"
+    },
+    "title": "Conversazioni",
+    "searchPlaceholder": "Cerca conversazioni",
+    "noSearchResults": "Nessuna conversazione corrisponde alla ricerca.",
+    "compose": {
+      "newMessage": "Nuovo messaggio",
+      "title": "Nuovo messaggio WhatsApp",
+      "searchPlaceholder": "Cerca clienti",
+      "noMatches": "Nessun cliente corrisponde alla ricerca.",
+      "noRecipients": "Ancora nessun cliente con un numero di telefono."
+    },
+    "windowBlocked": "Questo cliente non scrive da oltre 24 ore, quindi WhatsApp non consegnerà un messaggio adesso. Chiedigli di scriverti oppure configura un modello approvato.",
+    "windowBlockedAction": "Configura un modello",
+    "windowBlockedPlaceholder": "In attesa che scriva il cliente"
+  },
+  "sendPhoto": {
+    "action": "Invia su WhatsApp",
+    "title": "Invia la foto su WhatsApp",
+    "description": "Il cliente riceve questa foto nella sua chat WhatsApp.",
+    "captionPlaceholder": "Aggiungi una nota, per esempio cosa mostra la foto",
+    "send": "Invia",
+    "cancel": "Annulla",
+    "sent": "Foto inviata",
+    "sentAsTemplate": "Foto inviata come modello approvato",
+    "failed": "Impossibile inviare la foto"
+  }
+}

+ 4 - 2
messages/lt/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Komplektas vėl saugomas",
     "tire_set_dispose": "Padangų komplektas nurašytas",
     "tire_set_attach": "Prie padangų komplekto pridėta failų",
-    "tire_set_detach": "Failas pašalintas iš padangų komplekto"
+    "tire_set_detach": "Failas pašalintas iš padangų komplekto",
+    "whatsapp_send": "Išsiųsta „WhatsApp“ žinutė"
   },
   "summary": {
     "customField_create": "Sukurtas pasirinktinis laukas „{name}“",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Transporto priemonė {id} grąžinta iš archyvo",
     "vehicle_update": "Atnaujinta transporto priemonė {name}, pakeista: {fields}",
     "vehicle_updateNoChanges": "Atnaujinta transporto priemonė {name}, niekas nepasikeitė",
-    "webhook_create": "Sukurtas webhook {name}"
+    "webhook_create": "Sukurtas webhook {name}",
+    "whatsapp_send": "Klientui {name} išsiųsta „WhatsApp“ žinutė"
   }
 }

+ 2 - 1
messages/lt/customers.json

@@ -38,7 +38,8 @@
       "messages": "Žinutės",
       "requests": "Užklausos ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "„WhatsApp“"
     },
     "vehicleTable": {
       "plate": "Valst. Nr.",

+ 4 - 1
messages/lt/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Siųskite ir gaukite SMS žinutes su klientais tiesiogiai iš Torqvoice.",
     "tabs": {
       "inbox": "Gauta",
-      "scheduled": "Suplanuota"
+      "scheduled": "Suplanuota",
+      "sms": "SMS",
+      "whatsapp": "„WhatsApp“",
+      "telegram": "„Telegram“"
     },
     "smsNotConfiguredTitle": "SMS nesukonfigūruotas",
     "smsNotConfiguredHint": "Prijunkite SMS tiekėją, kad galėtumėte siųsti ir gauti žinutes čia. Suplanuoti el. laiškai veikia ir be to.",

+ 26 - 0
messages/lt/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Pokalbiai",
+    "searchPlaceholder": "Ieškoti pokalbių",
+    "noSearchResults": "Pagal paiešką pokalbių nerasta.",
+    "empty": "Pokalbių dar nėra.",
+    "selectConversation": "Pasirinkite pokalbį",
+    "back": "Atgal",
+    "newMessage": "Nauja žinutė",
+    "unknownContact": "Šis numeris dar nepriskirtas klientui. Norėdami atsakyti, pridėkite jį kaip klientą.",
+    "noChannelsTitle": "Nėra sukonfigūruoto kanalo",
+    "noChannelsHint": "Prijunkite SMS, „WhatsApp“ ar „Telegram“, kad rašytumėte klientams iš čia.",
+    "goToProviders": "Nustatyti tiekėją",
+    "chooseChannel": {
+      "title": "Kuriuo kanalu siųsti?",
+      "description": "Klientus galite pasiekti keliais būdais."
+    },
+    "deleteConversation": "Ištrinti pokalbį",
+    "deleteTitle": "Ištrinti šį pokalbį?",
+    "deleteDescription": "Pašalina jūsų pokalbio su {name} kopiją. Kliento telefone žinutės lieka.",
+    "deleteFailed": "Nepavyko ištrinti pokalbio",
+    "delete": "Ištrinti",
+    "cancel": "Atšaukti",
+    "loadMore": "Įkelti daugiau"
+  }
+}

+ 4 - 2
messages/lt/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Visi komplektai",
     "tireStorage": "Saugyklos išdėstymas",
     "tireSetDetails": "Padangų komplektas",
-    "tireForecast": "Kitas sezonas"
+    "tireForecast": "Kitas sezonas",
+    "whatsapp": "„WhatsApp“"
   },
   "search": "Ieškoti...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Daugiau",
     "auditLog": "Audito žurnalas",
     "aiAssistant": "DI asistentas",
-    "tireHotel": "Padangų viešbutis"
+    "tireHotel": "Padangų viešbutis",
+    "whatsapp": "„WhatsApp“"
   }
 }

+ 4 - 2
messages/lt/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "El. paštas",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Pastaba programoje"
+      "in_app": "Pastaba programoje",
+      "whatsapp": "„WhatsApp“"
     },
     "repeatLabel": "Kartoti",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Suplanuoti",
     "scheduled": "Žinutė suplanuota",
     "updated": "Suplanuota žinutė atnaujinta",
-    "saveError": "Nepavyko išsaugoti žinutės"
+    "saveError": "Nepavyko išsaugoti žinutės",
+    "whatsappNeedsTemplate": "Ši žinutė iškeliaus gerokai užsidarius 24 valandų langui, o „WhatsApp“ teksto šablonas nenustatytas, todėl siuntimas nepavyktų. Nustatykite jį: Nustatymai > Tiekėjai > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Suplanuoti žinutę",

+ 30 - 0
messages/lt/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Padangų viešbutis",
         "description": "Sezoninis padangų saugojimas"
+      },
+      "providers": {
+        "title": "Tiekėjai",
+        "description": "El. paštas, SMS, „WhatsApp“ ir „Telegram“"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Paruošimo kainos",
     "prepHint": "Kiek kainuoja kiekvienas paruošimo darbas. Palikus tuščią, jis niekada neapmokestinamas, o to ir reikia dirbtuvėms, kurios plovimą įskaičiuoja į saugojimą. Turintys kainą patys atsiranda pasiūlyme ir darbo užsakyme.",
     "prepNotCharged": "neapmokestinama"
+  },
+  "providers": {
+    "tabs": {
+      "email": "El. paštas",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "El. pašto nustatymai",
+        "description": "Nustatykite SMTP siuntimą, kad sąskaitos, pasiūlymai ir pranešimai keliautų tiesiai iš dirbtuvių."
+      },
+      "sms": {
+        "feature": "SMS žinutės",
+        "description": "Siųskite ir gaukite SMS su klientais tiesiai iš „Torqvoice“."
+      },
+      "whatsapp": {
+        "feature": "„WhatsApp“ žinutės",
+        "description": "Siųskite ir gaukite „WhatsApp“ žinutes, įskaitant detalių nuotraukas, tiesiai iš „Torqvoice“."
+      },
+      "telegram": {
+        "feature": "„Telegram“ žinutės",
+        "description": "Siųskite ir gaukite „Telegram“ žinutes su klientais tiesiai iš „Torqvoice“."
+      }
+    }
   }
 }

+ 179 - 0
messages/lt/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "„WhatsApp“",
+    "description": "Siųskite ir gaukite „WhatsApp“ žinutes su klientais.",
+    "helpLink": "Nustatyti {provider}",
+    "save": "Įrašyti „WhatsApp“ nustatymus",
+    "saved": "„WhatsApp“ nustatymai įrašyti",
+    "saveError": "Nepavyko įrašyti „WhatsApp“ nustatymų",
+    "enable": {
+      "label": "Įjungti „WhatsApp“",
+      "hint": "Įjunkite, kai tiekėjo prisijungimo duomenys suvesti.",
+      "disabledInfo": "Įjunkite „WhatsApp“, kad nustatytumėte tiekėją, numerį ir šabloną."
+    },
+    "provider": {
+      "label": "Tiekėjas",
+      "hint": "Kas suteikia jūsų „WhatsApp“ numerį. Pakeitus senojo duomenys išlieka."
+    },
+    "from": {
+      "label": "Jūsų „WhatsApp“ numeris",
+      "hint": "Numeris, kurį mato klientai, tarptautiniu formatu."
+    },
+    "secret": {
+      "show": "Rodyti",
+      "hide": "Slėpti",
+      "stored": "Įrašyta ir paslėpta. Įveskite naują reikšmę, kad pakeistumėte."
+    },
+    "webhook": {
+      "label": "Webhook URL",
+      "description": "Įklijuokite tai pas tiekėją, kad gaunamos žinutės pasiektų „Torqvoice“.",
+      "copy": "Kopijuoti webhook URL",
+      "copied": "Webhook URL nukopijuotas",
+      "saveFirst": "Įrašykite šiuos nustatymus prieš tiekėjui tikrinant URL. Tikrinama pagal tai, kas čia įrašyta, todėl neįrašyta forma testo neišlaikys."
+    },
+    "template": {
+      "title": "Žinutės šablonas",
+      "description": "Naudojamas, kai rašote klientui, kuris seniai nerašė.",
+      "windowExplainer": "„WhatsApp“ leidžia laisvą tekstą tik 24 valandas nuo paskutinės kliento žinutės. Vėliau žinutė turi būti tiekėjo patvirtintas šablonas.",
+      "nameLabel": "Šablono pavadinimas",
+      "nameHint": "Patvirtinto šablono pavadinimas arba „Content SID“ pas tiekėjus, kurie jį naudoja.",
+      "languageLabel": "Šablono kalba",
+      "languageHint": "Kalbos kodas, su kuriuo šablonas patvirtintas, pavyzdžiui lt.",
+      "variablesLabel": "Šablono reikšmės iš eilės",
+      "variablesHint": "Pasirinkite reikšmes, užpildančias sunumeruotas šablono vietas, ta pačia tvarka. Palikite tuščią, jei šablone vietų nėra.",
+      "tokens": {
+        "customer": "kliento vardas",
+        "vehicle": "metai, markė ir modelis",
+        "plate": "valstybinis numeris",
+        "message": "jūsų tekstas",
+        "workshop": "jūsų dirbtuvių pavadinimas",
+        "photo": "pridėta nuotrauka"
+      },
+      "variablesEmpty": "Reikšmių dar nėra. Pridėkite jas žemiau tokia tvarka, kokios tikisi šablonas.",
+      "variablesRemove": "Pašalinti {name}",
+      "mediaUrlLabel": "Medijos URL jūsų šablonui",
+      "mediaUrlHint": "Įklijuokite tai į šablono medijos lauką ir pakeiskite n nuotraukos reikšmės numeriu.",
+      "mediaUrlCopy": "Kopijuoti medijos URL",
+      "mediaUrlCopied": "Medijos URL nukopijuotas",
+      "mediaUrlSample": "Kaip pavyzdinę reikšmę pas tiekėją naudokite: sample",
+      "textTitle": "Tekstinės žinutės",
+      "textDescription": "Naudojama, kai rašote be nuotraukos: iš Žinučių, iš kliento „WhatsApp“ kortelės arba iš suplanuotos žinutės.",
+      "mediaTitle": "Žinutės su nuotrauka",
+      "mediaDescription": "Naudojama mygtuko „Siųsti per WhatsApp“ prie darbo nuotraukų ir visada, kai pokalbyje pridedate nuotrauką.",
+      "textMissing": "Nenustatyta: negalėsite rašyti klientui, kuris nerašė pastarąsias 24 valandas.",
+      "mediaMissing": "Nenustatyta: negalėsite siųsti nuotraukų klientui, kuris nerašė pastarąsias 24 valandas.",
+      "whatIsIt": "Šablonas – tai fiksuota žinutė, kurią parašote kartą ir kurią patvirtina {provider}: tekstas nekinta, užpildote tik sunumeruotas vietas. Parašykite ją jų konsolėje ir čia įklijuokite identifikatorių.",
+      "whyTwo": "Reikia dviejų, nes „WhatsApp“ medijos tipą nustato patvirtinimo metu. Su nuotrauka patvirtintas šablonas gali nešti tik nuotraukas, o kaip tekstas patvirtintas – niekada.",
+      "exampleLabel": "Pavyzdinis tekstas tiekėjo konsolei",
+      "exampleTextValues": "Užpildykite: customer, message. Rašykite klientų kalba.",
+      "exampleMediaValues": "Užpildykite: customer, message ir photo medijos URL. Rašykite klientų kalba.",
+      "exampleMediaValuesHeader": "Užpildykite: customer, message. Nuotrauka pridedama automatiškai, todėl jai atskiros vietos nereikia. Rašykite ta kalba, kuria skaito jūsų klientai."
+    },
+    "test": {
+      "send": "Siųsti bandymą",
+      "sent": "Bandomoji žinutė išsiųsta",
+      "failed": "Nepavyko išsiųsti bandomosios žinutės",
+      "hint": "Pirma parašykite sau per „WhatsApp“, kitaip bandymui reikės patvirtinto šablono."
+    },
+    "disconnect": {
+      "action": "Atjungti „WhatsApp“",
+      "hint": "Pašalina prisijungimo duomenis iš „Torqvoice“. Pokalbiai išlieka.",
+      "done": "„WhatsApp“ atjungtas",
+      "title": "Atjungti"
+    },
+    "savedIncomplete": "Įrašyta. Kad galėtumėte siųsti, dar trūksta: {fields}",
+    "fieldStillNeeded": "Dar reikia norint siųsti",
+    "steps": {
+      "openProvider": "Atidaryti {provider}",
+      "openNumber": "Rasti siuntėjo numerį",
+      "openToken": "Sukurti nuolatinį prieigos raktą",
+      "consolePath": "Meta aplinkoje eikite į:",
+      "provider": {
+        "title": "Pasirinkite tiekėją ir numerį",
+        "description": "„Twilio“ paleisti greičiau ir jis tvarko „Meta“ už jus. „Meta Cloud API“ kainuoja mažiau, bet verslo patvirtinimą atliekate patys. Įveskite numerį, kurį matys klientai, tarptautiniu formatu."
+      },
+      "credentials": {
+        "title": "Įveskite prisijungimo duomenis",
+        "description": "Nukopijuokite kiekvieną reikšmę iš {provider}. Kiekvienas laukas nurodo, kur ją rasti. Įrašykite, kai turėsite; likusius galėsite pridėti vėliau."
+      },
+      "webhook": {
+        "title": "Užregistruokite webhook",
+        "description": "Nukopijuokite šį adresą į {provider}, kad klientų žinutės pasiektų „Torqvoice“. Pirma įrašykite šį puslapį: adresas tikrinamas pagal tai, kas čia įrašyta.",
+        "open": "Atidaryti {provider} webhook nustatymus",
+        "recheck": "Tikrinti dar kartą",
+        "waiting": "Į šį adresą dar nieko neatėjo. Jis taps žalias, kai tiekėjas jį patvirtins arba pristatys žinutę.",
+        "seen": "Tiekėjas pasiekė šį adresą. Gaunamos žinutės atsiras skiltyje Žinutės."
+      },
+      "templates": {
+        "title": "Patvirtinkite šablonus",
+        "description": "Reikia tik tam, kad rašytumėte pirmi už 24 valandų lango ribų. Galite praleisti ir grįžti, kai susirašinėjimas veiks.",
+        "open": "Atidaryti {provider} šablonus"
+      },
+      "test": {
+        "title": "Išsiųskite bandomąją žinutę",
+        "description": "Pirma parašykite savo „WhatsApp“ numeriu iš savo telefono. Tai įrodo, kad webhook veikia, ir atveria 24 valandų langą, tad atsakymui šablono nereikės."
+      },
+      "back": "Atgal",
+      "continue": "Toliau"
+    },
+    "register": {
+      "title": "Užregistruokite numerį „Meta“",
+      "description": "Patvirtinus numerį, „Meta“ reikalauja atskiros registracijos. Pasirinkite šešių skaitmenų PIN arba naudokite esamą dviejų veiksmų patvirtinimo PIN. Užsirašykite jį: prireiks perkeliant ar iš naujo registruojant numerį.",
+      "action": "Registruoti numerį",
+      "done": "Numeris užregistruotas",
+      "failed": "Nepavyko užregistruoti numerio",
+      "limit": "„Meta“ leidžia 10 registracijos bandymų vienam numeriui per 72 valandas. Neteisingas PIN taip pat skaičiuojamas, tad pasitikrinkite."
+    }
+  },
+  "messages": {
+    "featureName": "„WhatsApp“ žinutės",
+    "featureDescription": "Siųskite ir gaukite „WhatsApp“ žinutes, įskaitant detalių nuotraukas, tiesiai iš „Torqvoice“.",
+    "noThreads": "Pokalbių dar nėra",
+    "noThreadsHint": "Žinutės atsiras čia, kai klientas parašys jūsų „WhatsApp“ numeriu arba jūs jam.",
+    "selectThread": "Pasirinkite pokalbį",
+    "backToThreads": "Pokalbiai",
+    "empty": "Čia dar nieko nėra.",
+    "placeholder": "Rašykite žinutę",
+    "send": "Siųsti",
+    "sendFailed": "Nepavyko išsiųsti žinutės",
+    "attach": "Pridėti nuotrauką",
+    "attachFailed": "Nepavyko pridėti nuotraukos",
+    "removeAttachment": "Pašalinti priedą",
+    "attachment": "Priedas",
+    "delete": "Ištrinti",
+    "deleteFailed": "Nepavyko ištrinti žinutės",
+    "sentAsTemplate": "šablonas",
+    "sentAsTemplateNotice": "Išsiųsta kaip patvirtintas šablonas, nes klientas nerašė pastarąsias 24 valandas.",
+    "windowClosed": "Klientas nerašė pastarąsias 24 valandas, todėl tai iškeliaus kaip patvirtintas šablonas.",
+    "unknownNumber": "Šis numeris dar nepriskirtas klientui. Norėdami atsakyti, pridėkite jį kaip klientą.",
+    "notConfigured": {
+      "title": "„WhatsApp“ neprijungtas",
+      "description": "Prijunkite „WhatsApp“ numerį, kad galėtumėte susirašinėti su klientais.",
+      "action": "Nustatyti „WhatsApp“"
+    },
+    "title": "Pokalbiai",
+    "searchPlaceholder": "Ieškoti pokalbių",
+    "noSearchResults": "Pagal paiešką pokalbių nerasta.",
+    "compose": {
+      "newMessage": "Nauja žinutė",
+      "title": "Nauja „WhatsApp“ žinutė",
+      "searchPlaceholder": "Ieškoti klientų",
+      "noMatches": "Pagal paiešką klientų nerasta.",
+      "noRecipients": "Dar nėra klientų su telefono numeriu."
+    },
+    "windowBlocked": "Šis klientas nerašė pastarąsias 24 valandas, todėl „WhatsApp“ dabar žinutės nepristatys. Paprašykite jo parašyti arba nustatykite patvirtintą šabloną.",
+    "windowBlockedAction": "Nustatyti šabloną",
+    "windowBlockedPlaceholder": "Laukiama, kol klientas parašys pirmas"
+  },
+  "sendPhoto": {
+    "action": "Siųsti per „WhatsApp“",
+    "title": "Siųsti nuotrauką per „WhatsApp“",
+    "description": "Klientas gaus šią nuotrauką savo „WhatsApp“ pokalbyje.",
+    "captionPlaceholder": "Pridėkite pastabą, pavyzdžiui ką rodo nuotrauka",
+    "send": "Siųsti",
+    "cancel": "Atšaukti",
+    "sent": "Nuotrauka išsiųsta",
+    "sentAsTemplate": "Nuotrauka išsiųsta kaip patvirtintas šablonas",
+    "failed": "Nepavyko išsiųsti nuotraukos"
+  }
+}

+ 4 - 2
messages/nb/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Lagret et sett på nytt",
     "tire_set_dispose": "Kasserte et dekksett",
     "tire_set_attach": "La til filer på et dekksett",
-    "tire_set_detach": "Fjernet en fil fra et dekksett"
+    "tire_set_detach": "Fjernet en fil fra et dekksett",
+    "whatsapp_send": "WhatsApp-melding sendt"
   },
   "summary": {
     "customField_create": "Opprettet egendefinert felt «{name}»",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Hentet kjøretøy {id} ut av arkivet",
     "vehicle_update": "Oppdaterte kjøretøy {name}, endret: {fields}",
     "vehicle_updateNoChanges": "Oppdaterte kjøretøy {name}, ingenting endret",
-    "webhook_create": "Opprettet webhook {name}"
+    "webhook_create": "Opprettet webhook {name}",
+    "whatsapp_send": "Sendte en WhatsApp-melding til {name}"
   }
 }

+ 2 - 1
messages/nb/customers.json

@@ -38,7 +38,8 @@
       "messages": "Meldinger",
       "requests": "Forespørsler ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Registreringsnummer",

+ 4 - 1
messages/nb/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Send og motta SMS-meldinger med kundene dine direkte fra Torqvoice.",
     "tabs": {
       "inbox": "Innboks",
-      "scheduled": "Planlagt"
+      "scheduled": "Planlagt",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS er ikke satt opp",
     "smsNotConfiguredHint": "Koble til en SMS-leverandør for å sende og motta meldinger her. Planlagt e-post fungerer uansett.",

+ 26 - 0
messages/nb/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Samtaler",
+    "searchPlaceholder": "Søk i samtaler",
+    "noSearchResults": "Ingen samtaler passer søket.",
+    "empty": "Ingen samtaler ennå.",
+    "selectConversation": "Velg en samtale",
+    "back": "Tilbake",
+    "newMessage": "Ny melding",
+    "unknownContact": "Dette nummeret tilhører ingen kunde ennå. Legg dem til som kunde for å svare.",
+    "noChannelsTitle": "Ingen meldingskanal satt opp",
+    "noChannelsHint": "Koble til SMS, WhatsApp eller Telegram for å skrive til kundene herfra.",
+    "goToProviders": "Sett opp en leverandør",
+    "chooseChannel": {
+      "title": "Hvilken kanal?",
+      "description": "Du har flere måter å nå kundene dine på."
+    },
+    "deleteConversation": "Slett samtale",
+    "deleteTitle": "Slette denne samtalen?",
+    "deleteDescription": "Fjerner din kopi av samtalen med {name}. Meldingene blir liggende på telefonen deres.",
+    "deleteFailed": "Kunne ikke slette samtalen",
+    "delete": "Slett",
+    "cancel": "Avbryt",
+    "loadMore": "Last mer"
+  }
+}

+ 4 - 2
messages/nb/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Alle dekksett",
     "tireStorage": "Lageroppsett",
     "tireSetDetails": "Dekksett",
-    "tireForecast": "Neste sesong"
+    "tireForecast": "Neste sesong",
+    "whatsapp": "WhatsApp"
   },
   "search": "Søk...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Mer",
     "auditLog": "Aktivitetslogg",
     "aiAssistant": "AI-assistent",
-    "tireHotel": "Dekkhotell"
+    "tireHotel": "Dekkhotell",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/nb/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-post",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Notat i appen"
+      "in_app": "Notat i appen",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Gjenta",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Planlegg",
     "scheduled": "Melding planlagt",
     "updated": "Planlagt melding oppdatert",
-    "saveError": "Kunne ikke lagre meldingen"
+    "saveError": "Kunne ikke lagre meldingen",
+    "whatsappNeedsTemplate": "Denne sendes lenge etter at kundens 24-timersvindu er lukket, og ingen WhatsApp-tekstmal er satt opp, så den ville feile ved sending. Sett opp en under Innstillinger > Leverandører > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Planlegg melding",

+ 30 - 0
messages/nb/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Dekkhotell",
         "description": "Sesonglagring av dekk"
+      },
+      "providers": {
+        "title": "Leverandører",
+        "description": "E-post, SMS, WhatsApp og Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Priser for klargjøring",
     "prepHint": "Hva hver type klargjøring koster. La en stå tom, så faktureres den aldri, som passer for verksteder som har vask inkludert i lagerprisen. De med pris legges automatisk på tilbud og arbeidsordre.",
     "prepNotCharged": "ikke fakturert"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-post",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "E-postinnstillinger",
+        "description": "Sett opp SMTP-sending slik at fakturaer, tilbud og varsler går direkte fra verkstedet."
+      },
+      "sms": {
+        "feature": "SMS-meldinger",
+        "description": "Send og motta SMS med kundene dine rett fra Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "WhatsApp-meldinger",
+        "description": "Send og motta WhatsApp-meldinger, inkludert bilder av deler, rett fra Torqvoice."
+      },
+      "telegram": {
+        "feature": "Telegram-meldinger",
+        "description": "Send og motta Telegram-meldinger med kundene dine rett fra Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/nb/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Send og motta WhatsApp-meldinger med kundene dine.",
+    "helpLink": "Sett opp {provider}",
+    "save": "Lagre WhatsApp-innstillinger",
+    "saved": "WhatsApp-innstillinger lagret",
+    "saveError": "Kunne ikke lagre WhatsApp-innstillingene",
+    "enable": {
+      "label": "Slå på WhatsApp",
+      "hint": "Slå på når legitimasjonen fra leverandøren er på plass.",
+      "disabledInfo": "Slå på WhatsApp for å sette opp leverandør, nummer og mal."
+    },
+    "provider": {
+      "label": "Leverandør",
+      "hint": "Hvem som leverer WhatsApp-nummeret ditt. Bytter du, beholdes legitimasjonen til den forrige."
+    },
+    "from": {
+      "label": "Ditt WhatsApp-nummer",
+      "hint": "Nummeret kundene ser, i internasjonalt format."
+    },
+    "secret": {
+      "show": "Vis",
+      "hide": "Skjul",
+      "stored": "Lagret og skjult. Skriv inn en ny verdi for å erstatte den."
+    },
+    "webhook": {
+      "label": "Webhook-URL",
+      "description": "Lim denne inn hos leverandøren så innkommende meldinger når Torqvoice.",
+      "copy": "Kopier webhook-URL",
+      "copied": "Webhook-URL kopiert",
+      "saveFirst": "Lagre disse innstillingene før leverandøren verifiserer URL-en. Den sjekkes mot det som er lagret her, så et ulagret skjema feiler."
+    },
+    "template": {
+      "title": "Meldingsmal",
+      "description": "Brukes når du skriver til en kunde som ikke har meldt fra på en stund.",
+      "windowExplainer": "WhatsApp tillater fritekst kun i 24 timer etter kundens siste melding. Utenfor det vinduet må meldingen gå ut som en mal leverandøren har godkjent.",
+      "nameLabel": "Malnavn",
+      "nameHint": "Navnet på den godkjente malen, eller Content SID hos leverandører som bruker det.",
+      "languageLabel": "Malspråk",
+      "languageHint": "Språkkoden malen ble godkjent under, for eksempel nb.",
+      "variablesLabel": "Verdier i malen, i rekkefølge",
+      "variablesHint": "Velg verdiene som fyller de nummererte feltene i malen, i samme rekkefølge. La stå tom hvis malen ikke har felter.",
+      "tokens": {
+        "customer": "kundenavn",
+        "vehicle": "årsmodell, merke og modell",
+        "plate": "registreringsnummer",
+        "message": "det du skriver",
+        "workshop": "navnet på verkstedet",
+        "photo": "bildet du legger ved"
+      },
+      "variablesEmpty": "Ingen verdier ennå. Legg dem til under, i rekkefølgen malen forventer.",
+      "variablesRemove": "Fjern {name}",
+      "mediaUrlLabel": "Medie-URL for malen din",
+      "mediaUrlHint": "Lim dette inn i malens mediefelt og bytt ut n med nummeret til bildeverdien.",
+      "mediaUrlCopy": "Kopier medie-URL",
+      "mediaUrlCopied": "Medie-URL kopiert",
+      "mediaUrlSample": "Som eksempelverdi hos leverandøren, bruk: sample",
+      "textTitle": "Tekstmeldinger",
+      "textDescription": "Brukes når du skriver uten bilde: fra Meldinger, fra WhatsApp-fanen på en kunde, eller fra en planlagt melding.",
+      "mediaTitle": "Meldinger med bilde",
+      "mediaDescription": "Brukes av Send på WhatsApp-knappen på bildene i en jobb, og hver gang du legger ved et bilde i en samtale.",
+      "textMissing": "Ikke satt opp: du kan ikke skrive til en kunde som ikke har meldt fra de siste 24 timene.",
+      "mediaMissing": "Ikke satt opp: du kan ikke sende bilder til en kunde som ikke har meldt fra de siste 24 timene.",
+      "whatIsIt": "En mal er en fast melding du skriver én gang og som {provider} godkjenner: ordlyden endres aldri, du fyller bare de nummererte feltene. Skriv den i deres konsoll og lim inn identifikatoren her.",
+      "whyTwo": "Du trenger to fordi WhatsApp låser medietypen ved godkjenning. En mal godkjent med bilde kan bare bære bilder, og en godkjent som tekst kan aldri bære ett.",
+      "exampleLabel": "Eksempeltekst å skrive i leverandørens konsoll",
+      "exampleTextValues": "Fyll den med: customer, message. Skriv den på språket kundene dine leser.",
+      "exampleMediaValues": "Fyll den med: customer, message og photo for medie-URL-en. Skriv den på språket kundene dine leser.",
+      "exampleMediaValuesHeader": "Fyll den med: customer, message. Bildet legges ved automatisk og trenger ingen egen plass. Skriv den på språket kundene dine leser."
+    },
+    "test": {
+      "send": "Send test",
+      "sent": "Testmelding sendt",
+      "failed": "Kunne ikke sende testmeldingen",
+      "hint": "Send deg selv en melding på WhatsApp først, ellers krever testen en godkjent mal."
+    },
+    "disconnect": {
+      "action": "Koble fra WhatsApp",
+      "hint": "Fjerner legitimasjonen fra Torqvoice. Samtalene beholdes.",
+      "done": "WhatsApp koblet fra",
+      "title": "Koble fra"
+    },
+    "savedIncomplete": "Lagret. Mangler fortsatt før du kan sende: {fields}",
+    "fieldStillNeeded": "Mangler fortsatt før du kan sende",
+    "steps": {
+      "openProvider": "Åpne {provider}",
+      "openNumber": "Finn avsendernummeret",
+      "openToken": "Lag et permanent token",
+      "consolePath": "I Meta går du til:",
+      "provider": {
+        "title": "Velg leverandør og nummer",
+        "description": "Twilio er raskere å komme i gang med og håndterer Meta for deg. Meta Cloud API koster mindre, men du tar bedriftsverifiseringen selv. Skriv inn nummeret kundene ser, i internasjonalt format."
+      },
+      "credentials": {
+        "title": "Legg inn legitimasjonen",
+        "description": "Kopier hver verdi fra {provider}. Hvert felt sier hvor du finner den. Lagre når du har dem; du kan komme tilbake for resten."
+      },
+      "webhook": {
+        "title": "Registrer webhooken",
+        "description": "Kopier denne adressen inn i {provider} slik at meldinger fra kunder når Torqvoice. Lagre denne siden først: adressen sjekkes mot det som er lagret her.",
+        "open": "Åpne webhook-innstillinger hos {provider}",
+        "recheck": "Sjekk på nytt",
+        "waiting": "Ingenting har nådd denne adressen ennå. Den blir grønn straks leverandøren verifiserer den eller leverer en melding.",
+        "seen": "Leverandøren har nådd denne adressen. Innkommende meldinger dukker opp under Meldinger."
+      },
+      "templates": {
+        "title": "Få malene godkjent",
+        "description": "Trengs bare for å skrive først, utenfor 24-timersvinduet. Du kan hoppe over nå og komme tilbake når meldinger virker.",
+        "open": "Åpne maler hos {provider}"
+      },
+      "test": {
+        "title": "Send en testmelding",
+        "description": "Send først en melding til WhatsApp-nummeret ditt fra din egen telefon. Det beviser at webhooken virker og åpner et 24-timersvindu, så svaret trenger ingen mal."
+      },
+      "back": "Tilbake",
+      "continue": "Fortsett"
+    },
+    "register": {
+      "title": "Registrer nummeret hos Meta",
+      "description": "Meta krever en egen registrering når nummeret er verifisert. Velg en sekssifret PIN, eller bruk nummerets eksisterende PIN for totrinnsbekreftelse. Skriv den ned: du trenger den for å flytte eller registrere nummeret på nytt.",
+      "action": "Registrer nummer",
+      "done": "Nummeret er registrert",
+      "failed": "Kunne ikke registrere nummeret",
+      "limit": "Meta tillater 10 registreringsforsøk per nummer på 72 timer. Feil PIN teller med, så sjekk den først."
+    }
+  },
+  "messages": {
+    "featureName": "WhatsApp-meldinger",
+    "featureDescription": "Send og motta WhatsApp-meldinger, inkludert bilder av deler, rett fra Torqvoice.",
+    "noThreads": "Ingen samtaler ennå",
+    "noThreadsHint": "Meldinger dukker opp her så snart en kunde skriver til WhatsApp-nummeret ditt, eller du skriver til dem.",
+    "selectThread": "Velg en samtale",
+    "backToThreads": "Samtaler",
+    "empty": "Ingenting her ennå.",
+    "placeholder": "Skriv en melding",
+    "send": "Send",
+    "sendFailed": "Kunne ikke sende meldingen",
+    "attach": "Legg ved et bilde",
+    "attachFailed": "Kunne ikke legge ved bildet",
+    "removeAttachment": "Fjern vedlegg",
+    "attachment": "Vedlegg",
+    "delete": "Slett",
+    "deleteFailed": "Kunne ikke slette meldingen",
+    "sentAsTemplate": "mal",
+    "sentAsTemplateNotice": "Sendt som den godkjente malen din, fordi kunden ikke har skrevet de siste 24 timene.",
+    "windowClosed": "Kunden har ikke skrevet de siste 24 timene, så dette går ut som den godkjente malen din.",
+    "unknownNumber": "Dette nummeret tilhører ingen kunde ennå. Legg dem til som kunde for å svare.",
+    "notConfigured": {
+      "title": "WhatsApp er ikke koblet til",
+      "description": "Koble til et WhatsApp-nummer for å sende og motta meldinger med kundene dine.",
+      "action": "Sett opp WhatsApp"
+    },
+    "title": "Samtaler",
+    "searchPlaceholder": "Søk i samtaler",
+    "noSearchResults": "Ingen samtaler passer søket.",
+    "compose": {
+      "newMessage": "Ny melding",
+      "title": "Ny WhatsApp-melding",
+      "searchPlaceholder": "Søk etter kunder",
+      "noMatches": "Ingen kunder passer søket.",
+      "noRecipients": "Ingen kunder med telefonnummer ennå."
+    },
+    "windowBlocked": "Denne kunden har ikke skrevet de siste 24 timene, så WhatsApp leverer ingen melding nå. Be dem sende deg en, eller sett opp en godkjent mal.",
+    "windowBlockedAction": "Sett opp en mal",
+    "windowBlockedPlaceholder": "Venter på at kunden skriver først"
+  },
+  "sendPhoto": {
+    "action": "Send på WhatsApp",
+    "title": "Send bilde på WhatsApp",
+    "description": "Kunden får dette bildet i WhatsApp-samtalen sin.",
+    "captionPlaceholder": "Legg til en merknad, for eksempel hva bildet viser",
+    "send": "Send",
+    "cancel": "Avbryt",
+    "sent": "Bilde sendt",
+    "sentAsTemplate": "Bilde sendt som den godkjente malen din",
+    "failed": "Kunne ikke sende bildet"
+  }
+}

+ 4 - 2
messages/nl/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Set opnieuw opgeslagen",
     "tire_set_dispose": "Bandenset afgeschreven",
     "tire_set_attach": "Bestanden aan een bandenset toegevoegd",
-    "tire_set_detach": "Bestand van een bandenset verwijderd"
+    "tire_set_detach": "Bestand van een bandenset verwijderd",
+    "whatsapp_send": "WhatsApp-bericht verzonden"
   },
   "summary": {
     "customField_create": "Aangepast veld \"{name}\" aangemaakt",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Voertuig {id} uit het archief gehaald",
     "vehicle_update": "Voertuig {name} bijgewerkt, gewijzigd: {fields}",
     "vehicle_updateNoChanges": "Voertuig {name} bijgewerkt, niets gewijzigd",
-    "webhook_create": "Webhook {name} aangemaakt"
+    "webhook_create": "Webhook {name} aangemaakt",
+    "whatsapp_send": "WhatsApp-bericht verzonden naar {name}"
   }
 }

+ 2 - 1
messages/nl/customers.json

@@ -38,7 +38,8 @@
       "messages": "Berichten",
       "requests": "Aanvragen ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Kenteken",

+ 4 - 1
messages/nl/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Verzend en ontvang SMS-berichten met uw klanten rechtstreeks vanuit Torqvoice.",
     "tabs": {
       "inbox": "Postvak in",
-      "scheduled": "Gepland"
+      "scheduled": "Gepland",
+      "sms": "Sms",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "Sms is niet ingesteld",
     "smsNotConfiguredHint": "Koppel een sms-provider om hier berichten te versturen en ontvangen. Geplande e-mail werkt ook zonder.",

+ 26 - 0
messages/nl/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Gesprekken",
+    "searchPlaceholder": "Gesprekken zoeken",
+    "noSearchResults": "Geen gesprek komt overeen met je zoekopdracht.",
+    "empty": "Nog geen gesprekken.",
+    "selectConversation": "Kies een gesprek",
+    "back": "Terug",
+    "newMessage": "Nieuw bericht",
+    "unknownContact": "Dit nummer hoort nog bij geen klant. Voeg ze als klant toe om te antwoorden.",
+    "noChannelsTitle": "Geen berichtenkanaal ingesteld",
+    "noChannelsHint": "Verbind sms, WhatsApp of Telegram om hiervandaan naar klanten te schrijven.",
+    "goToProviders": "Provider instellen",
+    "chooseChannel": {
+      "title": "Via welk kanaal?",
+      "description": "Je kunt je klanten op meerdere manieren bereiken."
+    },
+    "deleteConversation": "Gesprek verwijderen",
+    "deleteTitle": "Dit gesprek verwijderen?",
+    "deleteDescription": "Verwijdert jouw kopie van het gesprek met {name}. Op hun telefoon blijven de berichten staan.",
+    "deleteFailed": "Kon het gesprek niet verwijderen",
+    "delete": "Verwijderen",
+    "cancel": "Annuleren",
+    "loadMore": "Meer laden"
+  }
+}

+ 4 - 2
messages/nl/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Alle bandensets",
     "tireStorage": "Opslagindeling",
     "tireSetDetails": "Bandenset",
-    "tireForecast": "Volgend seizoen"
+    "tireForecast": "Volgend seizoen",
+    "whatsapp": "WhatsApp"
   },
   "search": "Zoeken...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Meer",
     "auditLog": "Activiteitenlog",
     "aiAssistant": "AI-assistent",
-    "tireHotel": "Bandenhotel"
+    "tireHotel": "Bandenhotel",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/nl/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-mail",
       "sms": "Sms",
       "telegram": "Telegram",
-      "in_app": "Notitie in de app"
+      "in_app": "Notitie in de app",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Herhalen",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Plannen",
     "scheduled": "Bericht gepland",
     "updated": "Gepland bericht bijgewerkt",
-    "saveError": "Kan het bericht niet opslaan"
+    "saveError": "Kan het bericht niet opslaan",
+    "whatsappNeedsTemplate": "Dit wordt ruim na het sluiten van het venster van 24 uur verstuurd en er is geen WhatsApp-tekstsjabloon ingesteld, dus het zou mislukken. Stel er een in via Instellingen > Providers > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Bericht plannen",

+ 30 - 0
messages/nl/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Bandenhotel",
         "description": "Seizoensopslag van banden"
+      },
+      "providers": {
+        "title": "Providers",
+        "description": "E-mail, sms, WhatsApp en Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Prijzen voorbereiding",
     "prepHint": "Wat elk soort voorbereiding kost. Laat er een leeg en die wordt nooit gefactureerd, wat past bij een bedrijf dat wassen in de opslagprijs meeneemt. Beprijsde komen automatisch op offerte en werkorder.",
     "prepNotCharged": "niet in rekening"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "E-mailinstellingen",
+        "description": "Stel SMTP-verzending in om facturen, offertes en meldingen direct vanuit de werkplaats te sturen."
+      },
+      "sms": {
+        "feature": "Sms-berichten",
+        "description": "Verstuur en ontvang sms met je klanten rechtstreeks vanuit Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "WhatsApp-berichten",
+        "description": "Verstuur en ontvang WhatsApp-berichten, inclusief foto's van onderdelen, vanuit Torqvoice."
+      },
+      "telegram": {
+        "feature": "Telegram-berichten",
+        "description": "Verstuur en ontvang Telegram-berichten met je klanten vanuit Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/nl/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Verstuur en ontvang WhatsApp-berichten met je klanten.",
+    "helpLink": "{provider} instellen",
+    "save": "WhatsApp-instellingen opslaan",
+    "saved": "WhatsApp-instellingen opgeslagen",
+    "saveError": "Kon de WhatsApp-instellingen niet opslaan",
+    "enable": {
+      "label": "WhatsApp inschakelen",
+      "hint": "Zet aan zodra de gegevens van je provider ingevuld zijn.",
+      "disabledInfo": "Schakel WhatsApp in om je provider, nummer en sjabloon in te stellen."
+    },
+    "provider": {
+      "label": "Provider",
+      "hint": "Wie je WhatsApp-nummer levert. Bij wisselen blijven de gegevens van de vorige bewaard."
+    },
+    "from": {
+      "label": "Je WhatsApp-nummer",
+      "hint": "Het nummer dat klanten zien, in internationaal formaat."
+    },
+    "secret": {
+      "show": "Tonen",
+      "hide": "Verbergen",
+      "stored": "Opgeslagen en verborgen. Typ een nieuwe waarde om hem te vervangen."
+    },
+    "webhook": {
+      "label": "Webhook-URL",
+      "description": "Plak dit bij je provider zodat inkomende berichten in Torqvoice aankomen.",
+      "copy": "Webhook-URL kopiëren",
+      "copied": "Webhook-URL gekopieerd",
+      "saveFirst": "Sla deze instellingen op voordat je provider de URL verifieert. Er wordt gecontroleerd tegen wat hier is opgeslagen, dus een niet-opgeslagen formulier faalt."
+    },
+    "template": {
+      "title": "Berichtsjabloon",
+      "description": "Gebruikt wanneer je een klant schrijft die je niet recent iets stuurde.",
+      "windowExplainer": "WhatsApp staat vrije tekst alleen toe binnen 24 uur na het laatste bericht van de klant. Daarbuiten moet je bericht als goedgekeurd sjabloon uitgaan.",
+      "nameLabel": "Naam van het sjabloon",
+      "nameHint": "De naam van het goedgekeurde sjabloon, of de Content SID bij providers die daarmee werken.",
+      "languageLabel": "Taal van het sjabloon",
+      "languageHint": "De taalcode waaronder het sjabloon is goedgekeurd, bijvoorbeeld nl.",
+      "variablesLabel": "Waarden van het sjabloon, op volgorde",
+      "variablesHint": "Kies de waarden die de genummerde plekken in je sjabloon invullen, in dezelfde volgorde. Laat leeg als je sjabloon er geen heeft.",
+      "tokens": {
+        "customer": "klantnaam",
+        "vehicle": "bouwjaar, merk en model",
+        "plate": "kenteken",
+        "message": "wat je typt",
+        "workshop": "naam van je werkplaats",
+        "photo": "de bijgevoegde foto"
+      },
+      "variablesEmpty": "Nog geen waarden. Voeg ze hieronder toe, in de volgorde die je sjabloon verwacht.",
+      "variablesRemove": "{name} verwijderen",
+      "mediaUrlLabel": "Media-URL voor je sjabloon",
+      "mediaUrlHint": "Plak dit in het mediaveld van het sjabloon en vervang n door het nummer van de fotowaarde.",
+      "mediaUrlCopy": "Media-URL kopiëren",
+      "mediaUrlCopied": "Media-URL gekopieerd",
+      "mediaUrlSample": "Gebruik als voorbeeldwaarde bij de provider: sample",
+      "textTitle": "Tekstberichten",
+      "textDescription": "Gebruikt wanneer je zonder foto schrijft: vanuit Berichten, vanuit het WhatsApp-tabblad bij een klant of vanuit een gepland bericht.",
+      "mediaTitle": "Berichten met foto",
+      "mediaDescription": "Gebruikt door de knop Via WhatsApp sturen bij de foto's van een werkorder, en telkens als je een foto toevoegt in een gesprek.",
+      "textMissing": "Niet ingesteld: je kunt geen klant schrijven die de afgelopen 24 uur niets stuurde.",
+      "mediaMissing": "Niet ingesteld: je kunt geen foto's sturen naar een klant die de afgelopen 24 uur niets stuurde.",
+      "whatIsIt": "Een sjabloon is een vast bericht dat je één keer schrijft en dat {provider} goedkeurt: de tekst verandert nooit, je vult alleen de genummerde plekken in. Schrijf het in hun console en plak de identificatie hier.",
+      "whyTwo": "Je hebt er twee nodig omdat WhatsApp het mediatype vastlegt bij goedkeuring. Een sjabloon goedgekeurd met foto kan alleen foto's dragen, een als tekst goedgekeurd sjabloon nooit.",
+      "exampleLabel": "Voorbeeldtekst voor de console van je provider",
+      "exampleTextValues": "Vul het met: customer, message. Schrijf het in de taal die je klanten lezen.",
+      "exampleMediaValues": "Vul het met: customer, message en photo voor de media-URL. Schrijf het in de taal die je klanten lezen.",
+      "exampleMediaValuesHeader": "Vul hem met: customer, message. De foto wordt automatisch meegestuurd en heeft dus geen eigen invulplek nodig. Schrijf hem in de taal die uw klanten lezen."
+    },
+    "test": {
+      "send": "Test versturen",
+      "sent": "Testbericht verstuurd",
+      "failed": "Kon het testbericht niet versturen",
+      "hint": "Stuur jezelf eerst een WhatsApp, anders heeft de test een goedgekeurd sjabloon nodig."
+    },
+    "disconnect": {
+      "action": "WhatsApp loskoppelen",
+      "hint": "Verwijdert de gegevens uit Torqvoice. Je gesprekken blijven bewaard.",
+      "done": "WhatsApp losgekoppeld",
+      "title": "Loskoppelen"
+    },
+    "savedIncomplete": "Opgeslagen. Nog nodig om te kunnen sturen: {fields}",
+    "fieldStillNeeded": "Nog nodig om te kunnen sturen",
+    "steps": {
+      "openProvider": "{provider} openen",
+      "openNumber": "Uw afzendernummer vinden",
+      "openToken": "Een permanent token maken",
+      "consolePath": "Ga in Meta naar:",
+      "provider": {
+        "title": "Kies je provider en nummer",
+        "description": "Twilio is sneller op te zetten en regelt Meta voor je. Meta Cloud API kost minder, maar de bedrijfsverificatie doe je zelf. Vul het nummer in dat klanten zien, in internationaal formaat."
+      },
+      "credentials": {
+        "title": "Vul je gegevens in",
+        "description": "Kopieer elke waarde uit {provider}. Elk veld zegt waar je die vindt. Sla op zodra je ze hebt; voor de rest kom je terug."
+      },
+      "webhook": {
+        "title": "Registreer de webhook",
+        "description": "Plak dit adres in {provider} zodat berichten van klanten bij Torqvoice aankomen. Sla eerst deze pagina op: het adres wordt gecontroleerd tegen wat hier is opgeslagen.",
+        "open": "Webhook-instellingen bij {provider} openen",
+        "recheck": "Opnieuw controleren",
+        "waiting": "Er is nog niets op dit adres binnengekomen. Het wordt groen zodra je provider het verifieert of een bericht aflevert.",
+        "seen": "Je provider heeft dit adres bereikt. Inkomende berichten verschijnen bij Berichten."
+      },
+      "templates": {
+        "title": "Laat je sjablonen goedkeuren",
+        "description": "Alleen nodig om als eerste te schrijven, buiten het venster van 24 uur. Je kunt dit overslaan en terugkomen als berichten werken.",
+        "open": "Sjablonen bij {provider} openen"
+      },
+      "test": {
+        "title": "Stuur een testbericht",
+        "description": "Stuur eerst vanaf je eigen telefoon een bericht naar je WhatsApp-nummer. Dat bewijst dat de webhook werkt en opent een venster van 24 uur, dus een antwoord heeft geen sjabloon nodig."
+      },
+      "back": "Terug",
+      "continue": "Doorgaan"
+    },
+    "register": {
+      "title": "Registreer het nummer bij Meta",
+      "description": "Meta vraagt om een aparte registratie zodra het nummer geverifieerd is. Kies een pincode van zes cijfers, of gebruik de bestaande pincode voor verificatie in twee stappen. Schrijf hem op: je hebt hem nodig om het nummer te verplaatsen of opnieuw te registreren.",
+      "action": "Nummer registreren",
+      "done": "Nummer geregistreerd",
+      "failed": "Kon het nummer niet registreren",
+      "limit": "Meta staat 10 registratiepogingen per nummer per 72 uur toe. Een verkeerde pincode telt mee, dus controleer hem eerst."
+    }
+  },
+  "messages": {
+    "featureName": "WhatsApp-berichten",
+    "featureDescription": "Verstuur en ontvang WhatsApp-berichten, inclusief foto's van onderdelen, vanuit Torqvoice.",
+    "noThreads": "Nog geen gesprekken",
+    "noThreadsHint": "Berichten verschijnen hier zodra een klant naar je WhatsApp-nummer schrijft, of jij naar hen.",
+    "selectThread": "Kies een gesprek",
+    "backToThreads": "Gesprekken",
+    "empty": "Hier is nog niets.",
+    "placeholder": "Schrijf een bericht",
+    "send": "Versturen",
+    "sendFailed": "Kon het bericht niet versturen",
+    "attach": "Foto toevoegen",
+    "attachFailed": "Kon de foto niet toevoegen",
+    "removeAttachment": "Bijlage verwijderen",
+    "attachment": "Bijlage",
+    "delete": "Verwijderen",
+    "deleteFailed": "Kon het bericht niet verwijderen",
+    "sentAsTemplate": "sjabloon",
+    "sentAsTemplateNotice": "Verstuurd als je goedgekeurde sjabloon, omdat de klant de afgelopen 24 uur niets stuurde.",
+    "windowClosed": "De klant schreef de afgelopen 24 uur niets, dus dit gaat als je goedgekeurde sjabloon de deur uit.",
+    "unknownNumber": "Dit nummer hoort nog bij geen klant. Voeg ze als klant toe om te antwoorden.",
+    "notConfigured": {
+      "title": "WhatsApp is niet verbonden",
+      "description": "Verbind een WhatsApp-nummer om berichten met je klanten uit te wisselen.",
+      "action": "WhatsApp instellen"
+    },
+    "title": "Gesprekken",
+    "searchPlaceholder": "Gesprekken zoeken",
+    "noSearchResults": "Geen gesprek komt overeen met je zoekopdracht.",
+    "compose": {
+      "newMessage": "Nieuw bericht",
+      "title": "Nieuw WhatsApp-bericht",
+      "searchPlaceholder": "Klanten zoeken",
+      "noMatches": "Geen klant komt overeen met je zoekopdracht.",
+      "noRecipients": "Nog geen klanten met een telefoonnummer."
+    },
+    "windowBlocked": "Deze klant schreef de afgelopen 24 uur niets, dus WhatsApp levert nu geen bericht af. Vraag ze iets te sturen of stel een goedgekeurd sjabloon in.",
+    "windowBlockedAction": "Sjabloon instellen",
+    "windowBlockedPlaceholder": "Wachten tot de klant eerst schrijft"
+  },
+  "sendPhoto": {
+    "action": "Via WhatsApp sturen",
+    "title": "Foto via WhatsApp sturen",
+    "description": "De klant krijgt deze foto in zijn WhatsApp-gesprek.",
+    "captionPlaceholder": "Voeg een notitie toe, bijvoorbeeld wat de foto laat zien",
+    "send": "Versturen",
+    "cancel": "Annuleren",
+    "sent": "Foto verstuurd",
+    "sentAsTemplate": "Foto verstuurd als je goedgekeurde sjabloon",
+    "failed": "Kon de foto niet versturen"
+  }
+}

+ 4 - 2
messages/pl/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Ponownie przyjęto komplet",
     "tire_set_dispose": "Spisano komplet opon",
     "tire_set_attach": "Dodano pliki do kompletu opon",
-    "tire_set_detach": "Usunięto plik z kompletu opon"
+    "tire_set_detach": "Usunięto plik z kompletu opon",
+    "whatsapp_send": "Wysłano wiadomość WhatsApp"
   },
   "summary": {
     "customField_create": "Utworzono pole niestandardowe „{name}”",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Przywrócono pojazd {id} z archiwum",
     "vehicle_update": "Zaktualizowano pojazd {name}, zmieniono: {fields}",
     "vehicle_updateNoChanges": "Zaktualizowano pojazd {name}, nic nie zmieniono",
-    "webhook_create": "Utworzono webhook {name}"
+    "webhook_create": "Utworzono webhook {name}",
+    "whatsapp_send": "Wysłano wiadomość WhatsApp do {name}"
   }
 }

+ 2 - 1
messages/pl/customers.json

@@ -38,7 +38,8 @@
       "messages": "Wiadomości",
       "requests": "Zlecenia ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Tablica",

+ 4 - 1
messages/pl/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Wysyłaj i odbieraj wiadomości SMS z klientami bezpośrednio z Torqvoice.",
     "tabs": {
       "inbox": "Odebrane",
-      "scheduled": "Zaplanowane"
+      "scheduled": "Zaplanowane",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS nie jest skonfigurowany",
     "smsNotConfiguredHint": "Podłącz dostawcę SMS, aby wysyłać i odbierać wiadomości tutaj. Zaplanowana poczta działa bez tego.",

+ 26 - 0
messages/pl/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Rozmowy",
+    "searchPlaceholder": "Szukaj rozmów",
+    "noSearchResults": "Żadna rozmowa nie pasuje do wyszukiwania.",
+    "empty": "Brak rozmów.",
+    "selectConversation": "Wybierz rozmowę",
+    "back": "Wstecz",
+    "newMessage": "Nowa wiadomość",
+    "unknownContact": "Ten numer nie należy jeszcze do żadnego klienta. Dodaj go jako klienta, aby odpowiedzieć.",
+    "noChannelsTitle": "Brak skonfigurowanego kanału",
+    "noChannelsHint": "Podłącz SMS, WhatsApp lub Telegram, aby pisać do klientów stąd.",
+    "goToProviders": "Skonfiguruj dostawcę",
+    "chooseChannel": {
+      "title": "Którym kanałem wysłać?",
+      "description": "Masz więcej niż jeden sposób kontaktu z klientami."
+    },
+    "deleteConversation": "Usuń rozmowę",
+    "deleteTitle": "Usunąć tę rozmowę?",
+    "deleteDescription": "Usuwa Twoją kopię rozmowy z {name}. Na telefonie klienta wiadomości zostają.",
+    "deleteFailed": "Nie udało się usunąć rozmowy",
+    "delete": "Usuń",
+    "cancel": "Anuluj",
+    "loadMore": "Wczytaj więcej"
+  }
+}

+ 4 - 2
messages/pl/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Wszystkie komplety",
     "tireStorage": "Układ magazynu",
     "tireSetDetails": "Komplet opon",
-    "tireForecast": "Następny sezon"
+    "tireForecast": "Następny sezon",
+    "whatsapp": "WhatsApp"
   },
   "search": "Szukaj...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Więcej",
     "auditLog": "Dziennik audytu",
     "aiAssistant": "Asystent AI",
-    "tireHotel": "Hotel opon"
+    "tireHotel": "Hotel opon",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/pl/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-mail",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Notatka w aplikacji"
+      "in_app": "Notatka w aplikacji",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Powtarzaj",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Zaplanuj",
     "scheduled": "Wiadomość zaplanowana",
     "updated": "Zaplanowana wiadomość zaktualizowana",
-    "saveError": "Nie udało się zapisać wiadomości"
+    "saveError": "Nie udało się zapisać wiadomości",
+    "whatsappNeedsTemplate": "Wiadomość wyjdzie długo po zamknięciu 24-godzinnego okna, a szablon tekstowy WhatsApp nie jest skonfigurowany, więc wysyłka by się nie powiodła. Skonfiguruj go w Ustawienia > Dostawcy > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Zaplanuj wiadomość",

+ 30 - 0
messages/pl/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Hotel opon",
         "description": "Sezonowe przechowywanie opon"
+      },
+      "providers": {
+        "title": "Dostawcy",
+        "description": "E-mail, SMS, WhatsApp i Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Ceny przygotowania",
     "prepHint": "Ile kosztuje każdy rodzaj przygotowania. Puste pole oznacza, że nigdy nie jest fakturowane, co pasuje warsztatom mającym mycie w cenie przechowywania. Wycenione trafiają automatycznie na wycenę i zlecenie.",
     "prepNotCharged": "nie naliczane"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Ustawienia e-mail",
+        "description": "Skonfiguruj wysyłkę SMTP, aby faktury, oferty i powiadomienia wychodziły prosto z warsztatu."
+      },
+      "sms": {
+        "feature": "Wiadomości SMS",
+        "description": "Wysyłaj i odbieraj SMS-y z klientami bezpośrednio w Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Wiadomości WhatsApp",
+        "description": "Wysyłaj i odbieraj wiadomości WhatsApp, w tym zdjęcia części, bezpośrednio w Torqvoice."
+      },
+      "telegram": {
+        "feature": "Wiadomości Telegram",
+        "description": "Wysyłaj i odbieraj wiadomości Telegram z klientami bezpośrednio w Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/pl/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Wysyłaj i odbieraj wiadomości WhatsApp z klientami.",
+    "helpLink": "Skonfiguruj {provider}",
+    "save": "Zapisz ustawienia WhatsApp",
+    "saved": "Ustawienia WhatsApp zapisane",
+    "saveError": "Nie udało się zapisać ustawień WhatsApp",
+    "enable": {
+      "label": "Włącz WhatsApp",
+      "hint": "Włącz, gdy dane dostawcy są już wpisane.",
+      "disabledInfo": "Włącz WhatsApp, aby skonfigurować dostawcę, numer i szablon."
+    },
+    "provider": {
+      "label": "Dostawca",
+      "hint": "Kto dostarcza Twój numer WhatsApp. Po zmianie dane poprzedniego zostają."
+    },
+    "from": {
+      "label": "Twój numer WhatsApp",
+      "hint": "Numer, który widzą klienci, w formacie międzynarodowym."
+    },
+    "secret": {
+      "show": "Pokaż",
+      "hide": "Ukryj",
+      "stored": "Zapisane i ukryte. Wpisz nową wartość, aby zastąpić."
+    },
+    "webhook": {
+      "label": "Adres webhooka",
+      "description": "Wklej go u dostawcy, aby przychodzące wiadomości trafiały do Torqvoice.",
+      "copy": "Kopiuj adres webhooka",
+      "copied": "Adres webhooka skopiowany",
+      "saveFirst": "Zapisz te ustawienia, zanim dostawca zweryfikuje adres. Sprawdzany jest zapisany stan, więc niezapisany formularz nie przejdzie weryfikacji."
+    },
+    "template": {
+      "title": "Szablon wiadomości",
+      "description": "Używany, gdy piszesz do klienta, który dawno się nie odzywał.",
+      "windowExplainer": "WhatsApp pozwala na dowolny tekst tylko przez 24 godziny od ostatniej wiadomości klienta. Poza tym oknem wiadomość musi wyjść jako szablon zatwierdzony przez dostawcę.",
+      "nameLabel": "Nazwa szablonu",
+      "nameHint": "Nazwa zatwierdzonego szablonu lub Content SID u dostawców, którzy go używają.",
+      "languageLabel": "Język szablonu",
+      "languageHint": "Kod języka, w którym szablon zatwierdzono, na przykład pl.",
+      "variablesLabel": "Wartości szablonu, po kolei",
+      "variablesHint": "Wybierz wartości wypełniające numerowane miejsca w szablonie, w tej samej kolejności. Zostaw puste, jeśli szablon ich nie ma.",
+      "tokens": {
+        "customer": "nazwa klienta",
+        "vehicle": "rocznik, marka i model",
+        "plate": "numer rejestracyjny",
+        "message": "to, co wpiszesz",
+        "workshop": "nazwa warsztatu",
+        "photo": "dołączone zdjęcie"
+      },
+      "variablesEmpty": "Brak wartości. Dodaj je poniżej, w kolejności oczekiwanej przez szablon.",
+      "variablesRemove": "Usuń {name}",
+      "mediaUrlLabel": "Adres media dla szablonu",
+      "mediaUrlHint": "Wklej to w pole media szablonu i zastąp n numerem wartości zdjęcia.",
+      "mediaUrlCopy": "Kopiuj adres media",
+      "mediaUrlCopied": "Adres media skopiowany",
+      "mediaUrlSample": "Jako wartość przykładową u dostawcy użyj: sample",
+      "textTitle": "Wiadomości tekstowe",
+      "textDescription": "Używany, gdy piszesz bez zdjęcia: z Wiadomości, z zakładki WhatsApp u klienta lub z zaplanowanej wiadomości.",
+      "mediaTitle": "Wiadomości ze zdjęciem",
+      "mediaDescription": "Używany przez przycisk Wyślij przez WhatsApp przy zdjęciach zlecenia i zawsze, gdy dołączasz zdjęcie w rozmowie.",
+      "textMissing": "Nieskonfigurowane: nie napiszesz do klienta, który nie pisał przez ostatnie 24 godziny.",
+      "mediaMissing": "Nieskonfigurowane: nie wyślesz zdjęć klientowi, który nie pisał przez ostatnie 24 godziny.",
+      "whatIsIt": "Szablon to stała wiadomość, którą piszesz raz i którą zatwierdza {provider}: treść się nie zmienia, wypełniasz tylko numerowane miejsca. Napisz ją w ich konsoli, a tutaj wklej identyfikator.",
+      "whyTwo": "Potrzebne są dwa, bo WhatsApp ustala typ medium przy zatwierdzaniu. Szablon zatwierdzony ze zdjęciem może nieść tylko zdjęcia, a zatwierdzony jako tekst nigdy ich nie poniesie.",
+      "exampleLabel": "Przykładowa treść do wpisania w konsoli dostawcy",
+      "exampleTextValues": "Wypełnij: customer, message. Napisz w języku swoich klientów.",
+      "exampleMediaValues": "Wypełnij: customer, message oraz photo dla adresu media. Napisz w języku swoich klientów.",
+      "exampleMediaValuesHeader": "Wypełnij ją: customer, message. Zdjęcie dołączane jest automatycznie, więc nie potrzebuje własnej luki. Napisz ją w języku, którym czytają twoi klienci."
+    },
+    "test": {
+      "send": "Wyślij test",
+      "sent": "Wiadomość testowa wysłana",
+      "failed": "Nie udało się wysłać wiadomości testowej",
+      "hint": "Najpierw napisz do siebie na WhatsAppie, inaczej test będzie wymagał zatwierdzonego szablonu."
+    },
+    "disconnect": {
+      "action": "Rozłącz WhatsApp",
+      "hint": "Usuwa dane logowania z Torqvoice. Rozmowy pozostają.",
+      "done": "WhatsApp rozłączony",
+      "title": "Rozłącz"
+    },
+    "savedIncomplete": "Zapisano. Do wysyłania brakuje jeszcze: {fields}",
+    "fieldStillNeeded": "Wciąż potrzebne do wysyłania",
+    "steps": {
+      "openProvider": "Otwórz {provider}",
+      "openNumber": "Znajdź numer nadawcy",
+      "openToken": "Utwórz stały token",
+      "consolePath": "W Meta przejdź do:",
+      "provider": {
+        "title": "Wybierz dostawcę i numer",
+        "description": "Twilio uruchomisz szybciej i obsłuży Metę za Ciebie. Meta Cloud API kosztuje mniej, ale weryfikację firmy przechodzisz samodzielnie. Wpisz numer, który zobaczą klienci, w formacie międzynarodowym."
+      },
+      "credentials": {
+        "title": "Wprowadź dane dostępowe",
+        "description": "Skopiuj każdą wartość z {provider}. Każde pole mówi, gdzie ją znaleźć. Zapisz, gdy je masz; po resztę możesz wrócić."
+      },
+      "webhook": {
+        "title": "Zarejestruj webhook",
+        "description": "Wklej ten adres w {provider}, aby wiadomości od klientów trafiały do Torqvoice. Najpierw zapisz tę stronę: adres jest sprawdzany względem tego, co tu zapisano.",
+        "open": "Otwórz ustawienia webhooka w {provider}",
+        "recheck": "Sprawdź ponownie",
+        "waiting": "Na ten adres jeszcze nic nie dotarło. Zmieni kolor na zielony, gdy dostawca go zweryfikuje lub dostarczy wiadomość.",
+        "seen": "Dostawca dotarł pod ten adres. Przychodzące wiadomości pojawią się w Wiadomościach."
+      },
+      "templates": {
+        "title": "Zatwierdź szablony",
+        "description": "Potrzebne tylko, aby napisać pierwszym poza oknem 24 godzin. Możesz to pominąć i wrócić, gdy wiadomości zaczną działać.",
+        "open": "Otwórz szablony {provider}"
+      },
+      "test": {
+        "title": "Wyślij wiadomość testową",
+        "description": "Najpierw napisz na swój numer WhatsApp z własnego telefonu. To potwierdza działanie webhooka i otwiera okno 24 godzin, więc odpowiedź nie potrzebuje szablonu."
+      },
+      "back": "Wstecz",
+      "continue": "Dalej"
+    },
+    "register": {
+      "title": "Zarejestruj numer w Meta",
+      "description": "Po weryfikacji Meta wymaga osobnej rejestracji. Wybierz sześciocyfrowy PIN albo użyj istniejącego PIN-u weryfikacji dwuetapowej. Zapisz go: będzie potrzebny przy przenoszeniu lub ponownej rejestracji numeru.",
+      "action": "Zarejestruj numer",
+      "done": "Numer zarejestrowany",
+      "failed": "Nie udało się zarejestrować numeru",
+      "limit": "Meta pozwala na 10 prób rejestracji numeru w ciągu 72 godzin. Błędny PIN też się liczy, więc sprawdź go wcześniej."
+    }
+  },
+  "messages": {
+    "featureName": "Wiadomości WhatsApp",
+    "featureDescription": "Wysyłaj i odbieraj wiadomości WhatsApp, w tym zdjęcia części, bezpośrednio w Torqvoice.",
+    "noThreads": "Brak rozmów",
+    "noThreadsHint": "Wiadomości pojawią się tutaj, gdy klient napisze na Twój numer WhatsApp albo Ty napiszesz do niego.",
+    "selectThread": "Wybierz rozmowę",
+    "backToThreads": "Rozmowy",
+    "empty": "Jeszcze nic tu nie ma.",
+    "placeholder": "Napisz wiadomość",
+    "send": "Wyślij",
+    "sendFailed": "Nie udało się wysłać wiadomości",
+    "attach": "Dołącz zdjęcie",
+    "attachFailed": "Nie udało się dołączyć zdjęcia",
+    "removeAttachment": "Usuń załącznik",
+    "attachment": "Załącznik",
+    "delete": "Usuń",
+    "deleteFailed": "Nie udało się usunąć wiadomości",
+    "sentAsTemplate": "szablon",
+    "sentAsTemplateNotice": "Wysłano jako zatwierdzony szablon, bo klient nie pisał przez ostatnie 24 godziny.",
+    "windowClosed": "Klient nie pisał przez ostatnie 24 godziny, więc wiadomość wyjdzie jako zatwierdzony szablon.",
+    "unknownNumber": "Ten numer nie należy jeszcze do żadnego klienta. Dodaj go jako klienta, aby odpowiedzieć.",
+    "notConfigured": {
+      "title": "WhatsApp nie jest połączony",
+      "description": "Podłącz numer WhatsApp, aby wymieniać wiadomości z klientami.",
+      "action": "Skonfiguruj WhatsApp"
+    },
+    "title": "Rozmowy",
+    "searchPlaceholder": "Szukaj rozmów",
+    "noSearchResults": "Żadna rozmowa nie pasuje do wyszukiwania.",
+    "compose": {
+      "newMessage": "Nowa wiadomość",
+      "title": "Nowa wiadomość WhatsApp",
+      "searchPlaceholder": "Szukaj klientów",
+      "noMatches": "Żaden klient nie pasuje do wyszukiwania.",
+      "noRecipients": "Brak klientów z numerem telefonu."
+    },
+    "windowBlocked": "Ten klient nie pisał przez ostatnie 24 godziny, więc WhatsApp nie dostarczy teraz wiadomości. Poproś go o wiadomość albo skonfiguruj zatwierdzony szablon.",
+    "windowBlockedAction": "Skonfiguruj szablon",
+    "windowBlockedPlaceholder": "Czekamy, aż klient napisze pierwszy"
+  },
+  "sendPhoto": {
+    "action": "Wyślij przez WhatsApp",
+    "title": "Wyślij zdjęcie przez WhatsApp",
+    "description": "Klient dostanie to zdjęcie na czacie WhatsApp.",
+    "captionPlaceholder": "Dodaj notatkę, na przykład co widać na zdjęciu",
+    "send": "Wyślij",
+    "cancel": "Anuluj",
+    "sent": "Zdjęcie wysłane",
+    "sentAsTemplate": "Zdjęcie wysłane jako zatwierdzony szablon",
+    "failed": "Nie udało się wysłać zdjęcia"
+  }
+}

+ 4 - 2
messages/pt-BR/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Jogo armazenado novamente",
     "tire_set_dispose": "Jogo de pneus baixado",
     "tire_set_attach": "Arquivos adicionados a um jogo de pneus",
-    "tire_set_detach": "Arquivo removido de um jogo de pneus"
+    "tire_set_detach": "Arquivo removido de um jogo de pneus",
+    "whatsapp_send": "Mensagem de WhatsApp enviada"
   },
   "summary": {
     "customField_create": "Campo personalizado \"{name}\" criado",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Veículo {id} retirado do arquivo",
     "vehicle_update": "Veículo {name} atualizado, alterado: {fields}",
     "vehicle_updateNoChanges": "Veículo {name} atualizado, nada alterado",
-    "webhook_create": "Webhook {name} criado"
+    "webhook_create": "Webhook {name} criado",
+    "whatsapp_send": "Enviou uma mensagem de WhatsApp para {name}"
   }
 }

+ 2 - 1
messages/pt-BR/customers.json

@@ -38,7 +38,8 @@
       "messages": "Mensagens",
       "requests": "Solicitações ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Placa",

+ 4 - 1
messages/pt-BR/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Envie e receba mensagens SMS com seus clientes diretamente pelo Torqvoice.",
     "tabs": {
       "inbox": "Caixa de entrada",
-      "scheduled": "Agendadas"
+      "scheduled": "Agendadas",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "O SMS não está configurado",
     "smsNotConfiguredHint": "Conecte um provedor de SMS para enviar e receber mensagens aqui. E-mails agendados funcionam sem isso.",

+ 26 - 0
messages/pt-BR/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Conversas",
+    "searchPlaceholder": "Buscar conversas",
+    "noSearchResults": "Nenhuma conversa corresponde à sua busca.",
+    "empty": "Ainda não há conversas.",
+    "selectConversation": "Escolha uma conversa",
+    "back": "Voltar",
+    "newMessage": "Nova mensagem",
+    "unknownContact": "Este número ainda não pertence a nenhum cliente. Cadastre-o como cliente para responder.",
+    "noChannelsTitle": "Nenhum canal configurado",
+    "noChannelsHint": "Conecte SMS, WhatsApp ou Telegram para falar com seus clientes por aqui.",
+    "goToProviders": "Configurar um provedor",
+    "chooseChannel": {
+      "title": "Enviar por qual canal?",
+      "description": "Você tem mais de uma forma de falar com seus clientes."
+    },
+    "deleteConversation": "Excluir conversa",
+    "deleteTitle": "Excluir esta conversa?",
+    "deleteDescription": "Remove sua cópia da conversa com {name}. As mensagens continuam no telefone do cliente.",
+    "deleteFailed": "Não foi possível excluir a conversa",
+    "delete": "Excluir",
+    "cancel": "Cancelar",
+    "loadMore": "Carregar mais"
+  }
+}

+ 4 - 2
messages/pt-BR/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Todos os jogos",
     "tireStorage": "Layout da armazenagem",
     "tireSetDetails": "Jogo de pneus",
-    "tireForecast": "Próxima temporada"
+    "tireForecast": "Próxima temporada",
+    "whatsapp": "WhatsApp"
   },
   "search": "Buscar...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Mais",
     "auditLog": "Registro de auditoria",
     "aiAssistant": "Assistente IA",
-    "tireHotel": "Hotel de pneus"
+    "tireHotel": "Hotel de pneus",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/pt-BR/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-mail",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Nota no app"
+      "in_app": "Nota no app",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Repetir",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Agendar",
     "scheduled": "Mensagem agendada",
     "updated": "Mensagem agendada atualizada",
-    "saveError": "Não foi possível salvar a mensagem"
+    "saveError": "Não foi possível salvar a mensagem",
+    "whatsappNeedsTemplate": "Isto será enviado bem depois de a janela de 24 horas fechar e não há modelo de texto do WhatsApp configurado, então falharia no envio. Configure um em Configurações > Provedores > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Agendar mensagem",

+ 30 - 0
messages/pt-BR/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Hotel de pneus",
         "description": "Armazenagem sazonal de pneus"
+      },
+      "providers": {
+        "title": "Provedores",
+        "description": "E-mail, SMS, WhatsApp e Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Preços da preparação",
     "prepHint": "Quanto custa cada tipo de preparação. Deixe em branco e nunca é cobrado, que é o que quer a oficina que inclui a lavagem na armazenagem. Os com preço entram sozinhos no orçamento e na ordem.",
     "prepNotCharged": "não cobrado"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-mail",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Configurações de e-mail",
+        "description": "Configure o envio SMTP para entregar faturas, orçamentos e avisos direto da sua oficina."
+      },
+      "sms": {
+        "feature": "Mensagens SMS",
+        "description": "Envie e receba SMS com seus clientes direto do Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Mensagens WhatsApp",
+        "description": "Envie e receba mensagens de WhatsApp, incluindo fotos de peças, direto do Torqvoice."
+      },
+      "telegram": {
+        "feature": "Mensagens Telegram",
+        "description": "Envie e receba mensagens do Telegram com seus clientes direto do Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/pt-BR/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Envie e receba mensagens de WhatsApp com seus clientes.",
+    "helpLink": "Configurar {provider}",
+    "save": "Salvar configurações do WhatsApp",
+    "saved": "Configurações do WhatsApp salvas",
+    "saveError": "Não foi possível salvar as configurações do WhatsApp",
+    "enable": {
+      "label": "Ativar WhatsApp",
+      "hint": "Ative quando as credenciais do provedor estiverem preenchidas.",
+      "disabledInfo": "Ative o WhatsApp para configurar provedor, número e modelo."
+    },
+    "provider": {
+      "label": "Provedor",
+      "hint": "Quem fornece seu número de WhatsApp. Ao trocar, as credenciais do anterior são mantidas."
+    },
+    "from": {
+      "label": "Seu número de WhatsApp",
+      "hint": "O número que os clientes veem, em formato internacional."
+    },
+    "secret": {
+      "show": "Mostrar",
+      "hide": "Ocultar",
+      "stored": "Salvo e oculto. Digite um novo valor para substituir."
+    },
+    "webhook": {
+      "label": "URL do webhook",
+      "description": "Cole isto no seu provedor para que as mensagens recebidas cheguem ao Torqvoice.",
+      "copy": "Copiar URL do webhook",
+      "copied": "URL do webhook copiada",
+      "saveFirst": "Salve estas configurações antes de o provedor verificar a URL. A checagem usa o que está salvo aqui, então um formulário não salvo falha."
+    },
+    "template": {
+      "title": "Modelo de mensagem",
+      "description": "Usado quando você escreve para um cliente que não falou com você recentemente.",
+      "windowExplainer": "O WhatsApp só permite texto livre por 24 horas após a última mensagem do cliente. Fora dessa janela, sua mensagem precisa sair como um modelo aprovado pelo provedor.",
+      "nameLabel": "Nome do modelo",
+      "nameHint": "O nome do modelo aprovado, ou o Content SID em provedores que usam um.",
+      "languageLabel": "Idioma do modelo",
+      "languageHint": "O código de idioma em que o modelo foi aprovado, por exemplo pt_BR.",
+      "variablesLabel": "Valores do modelo, em ordem",
+      "variablesHint": "Escolha os valores que preenchem os espaços numerados do seu modelo, na mesma ordem. Deixe vazio se o modelo não tiver espaços.",
+      "tokens": {
+        "customer": "nome do cliente",
+        "vehicle": "ano, marca e modelo",
+        "plate": "placa",
+        "message": "o que você digitar",
+        "workshop": "nome da sua oficina",
+        "photo": "a foto anexada"
+      },
+      "variablesEmpty": "Ainda sem valores. Adicione abaixo, na ordem que seu modelo espera.",
+      "variablesRemove": "Remover {name}",
+      "mediaUrlLabel": "URL de mídia para o seu modelo",
+      "mediaUrlHint": "Cole isto no campo de mídia do modelo e troque n pelo número do valor da foto.",
+      "mediaUrlCopy": "Copiar URL de mídia",
+      "mediaUrlCopied": "URL de mídia copiada",
+      "mediaUrlSample": "Como valor de exemplo no provedor, use: sample",
+      "textTitle": "Mensagens de texto",
+      "textDescription": "Usado quando você escreve sem foto: em Mensagens, na aba WhatsApp de um cliente ou em uma mensagem agendada.",
+      "mediaTitle": "Mensagens com foto",
+      "mediaDescription": "Usado pelo botão Enviar pelo WhatsApp nas fotos de uma ordem de serviço, e sempre que você anexa uma foto em uma conversa.",
+      "textMissing": "Não configurado: você não pode escrever para um cliente que não escreve há 24 horas.",
+      "mediaMissing": "Não configurado: você não pode enviar fotos para um cliente que não escreve há 24 horas.",
+      "whatIsIt": "Um modelo é uma mensagem fixa que você escreve uma vez e o {provider} aprova: o texto nunca muda, você só preenche os espaços numerados. Escreva no console deles e cole aqui o identificador.",
+      "whyTwo": "São necessários dois porque o WhatsApp fixa o tipo de mídia na aprovação. Um aprovado com foto só pode levar fotos, e um aprovado como texto nunca levará.",
+      "exampleLabel": "Corpo de exemplo para escrever no console do provedor",
+      "exampleTextValues": "Preencha com: customer, message. Escreva no idioma que seus clientes leem.",
+      "exampleMediaValues": "Preencha com: customer, message e photo para a URL de mídia. Escreva no idioma que seus clientes leem.",
+      "exampleMediaValuesHeader": "Preencha com: customer, message. A foto é anexada automaticamente, então não precisa de lacuna própria. Escreva no idioma que seus clientes leem."
+    },
+    "test": {
+      "send": "Enviar teste",
+      "sent": "Mensagem de teste enviada",
+      "failed": "Não foi possível enviar a mensagem de teste",
+      "hint": "Mande uma mensagem para você mesmo no WhatsApp antes, senão o teste exige um modelo aprovado."
+    },
+    "disconnect": {
+      "action": "Desconectar WhatsApp",
+      "hint": "Remove as credenciais do Torqvoice. Suas conversas são mantidas.",
+      "done": "WhatsApp desconectado",
+      "title": "Desconectar"
+    },
+    "savedIncomplete": "Salvo. Ainda falta para poder enviar: {fields}",
+    "fieldStillNeeded": "Ainda necessário para enviar",
+    "steps": {
+      "openProvider": "Abrir {provider}",
+      "openNumber": "Encontrar seu número de remetente",
+      "openToken": "Criar um token permanente",
+      "consolePath": "No Meta, vá para:",
+      "provider": {
+        "title": "Escolha o provedor e o número",
+        "description": "O Twilio começa mais rápido e cuida da Meta para você. A Meta Cloud API custa menos, mas a verificação da empresa é você quem faz. Informe o número que os clientes verão, em formato internacional."
+      },
+      "credentials": {
+        "title": "Informe suas credenciais",
+        "description": "Copie cada valor do {provider}. Cada campo diz onde encontrá-lo. Salve quando tiver; dá para voltar depois pelo resto."
+      },
+      "webhook": {
+        "title": "Registre o webhook",
+        "description": "Cole este endereço no {provider} para que as mensagens dos clientes cheguem ao Torqvoice. Salve esta página antes: o endereço é conferido com o que está salvo aqui.",
+        "open": "Abrir configurações de webhook do {provider}",
+        "recheck": "Verificar de novo",
+        "waiting": "Nada chegou a este endereço ainda. Ele fica verde assim que o provedor o verificar ou entregar uma mensagem.",
+        "seen": "Seu provedor alcançou este endereço. As mensagens recebidas aparecerão em Mensagens."
+      },
+      "templates": {
+        "title": "Aprove seus modelos",
+        "description": "Só é preciso para escrever primeiro, fora da janela de 24 horas. Pode pular agora e voltar quando as mensagens funcionarem.",
+        "open": "Abrir modelos do {provider}"
+      },
+      "test": {
+        "title": "Envie uma mensagem de teste",
+        "description": "Mande primeiro uma mensagem do seu celular para o seu número de WhatsApp. Isso prova que o webhook funciona e abre uma janela de 24 horas, então a resposta não precisa de modelo."
+      },
+      "back": "Voltar",
+      "continue": "Continuar"
+    },
+    "register": {
+      "title": "Registre o número na Meta",
+      "description": "A Meta exige um registro separado depois que o número é verificado. Escolha um PIN de seis dígitos, ou use o PIN de verificação em duas etapas se já houver um. Anote: você precisará dele para mover ou registrar o número de novo.",
+      "action": "Registrar número",
+      "done": "Número registrado",
+      "failed": "Não foi possível registrar o número",
+      "limit": "A Meta permite 10 tentativas de registro por número em 72 horas. Um PIN errado conta, então confira antes."
+    }
+  },
+  "messages": {
+    "featureName": "Mensagens WhatsApp",
+    "featureDescription": "Envie e receba mensagens de WhatsApp, incluindo fotos de peças, direto do Torqvoice.",
+    "noThreads": "Ainda não há conversas",
+    "noThreadsHint": "As mensagens aparecem aqui assim que um cliente escrever para o seu número de WhatsApp, ou você para ele.",
+    "selectThread": "Escolha uma conversa",
+    "backToThreads": "Conversas",
+    "empty": "Ainda não há nada aqui.",
+    "placeholder": "Escreva uma mensagem",
+    "send": "Enviar",
+    "sendFailed": "Não foi possível enviar a mensagem",
+    "attach": "Anexar uma foto",
+    "attachFailed": "Não foi possível anexar a foto",
+    "removeAttachment": "Remover anexo",
+    "attachment": "Anexo",
+    "delete": "Excluir",
+    "deleteFailed": "Não foi possível excluir a mensagem",
+    "sentAsTemplate": "modelo",
+    "sentAsTemplateNotice": "Enviado como seu modelo aprovado, porque o cliente não escreve há mais de 24 horas.",
+    "windowClosed": "O cliente não escreve há mais de 24 horas, então isto sairá como seu modelo aprovado.",
+    "unknownNumber": "Este número ainda não pertence a nenhum cliente. Cadastre-o como cliente para responder.",
+    "notConfigured": {
+      "title": "O WhatsApp não está conectado",
+      "description": "Conecte um número de WhatsApp para trocar mensagens com seus clientes.",
+      "action": "Configurar WhatsApp"
+    },
+    "title": "Conversas",
+    "searchPlaceholder": "Buscar conversas",
+    "noSearchResults": "Nenhuma conversa corresponde à sua busca.",
+    "compose": {
+      "newMessage": "Nova mensagem",
+      "title": "Nova mensagem de WhatsApp",
+      "searchPlaceholder": "Buscar clientes",
+      "noMatches": "Nenhum cliente corresponde à sua busca.",
+      "noRecipients": "Ainda não há clientes com telefone."
+    },
+    "windowBlocked": "Este cliente não escreve há mais de 24 horas, então o WhatsApp não vai entregar a mensagem agora. Peça que ele mande uma, ou configure um modelo aprovado.",
+    "windowBlockedAction": "Configurar um modelo",
+    "windowBlockedPlaceholder": "Aguardando o cliente escrever primeiro"
+  },
+  "sendPhoto": {
+    "action": "Enviar pelo WhatsApp",
+    "title": "Enviar foto pelo WhatsApp",
+    "description": "O cliente recebe esta foto na conversa de WhatsApp.",
+    "captionPlaceholder": "Adicione uma observação, por exemplo o que a foto mostra",
+    "send": "Enviar",
+    "cancel": "Cancelar",
+    "sent": "Foto enviada",
+    "sentAsTemplate": "Foto enviada como seu modelo aprovado",
+    "failed": "Não foi possível enviar a foto"
+  }
+}

+ 4 - 2
messages/ru/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Комплект снова на хранении",
     "tire_set_dispose": "Комплект шин списан",
     "tire_set_attach": "Файлы добавлены к комплекту шин",
-    "tire_set_detach": "Файл удалён из комплекта шин"
+    "tire_set_detach": "Файл удалён из комплекта шин",
+    "whatsapp_send": "Отправлено сообщение WhatsApp"
   },
   "summary": {
     "customField_create": "Создано настраиваемое поле «{name}»",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "Автомобиль {id} возвращён из архива",
     "vehicle_update": "Обновлён автомобиль {name}, изменено: {fields}",
     "vehicle_updateNoChanges": "Обновлён автомобиль {name}, изменений нет",
-    "webhook_create": "Создан вебхук {name}"
+    "webhook_create": "Создан вебхук {name}",
+    "whatsapp_send": "Отправлено сообщение WhatsApp клиенту {name}"
   }
 }

+ 2 - 1
messages/ru/customers.json

@@ -38,7 +38,8 @@
       "messages": "Сообщения",
       "requests": "Заявки ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Госномер",

+ 4 - 1
messages/ru/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Отправляйте и получайте SMS-сообщения с клиентами прямо из Torqvoice.",
     "tabs": {
       "inbox": "Входящие",
-      "scheduled": "Запланированные"
+      "scheduled": "Запланированные",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS не настроен",
     "smsNotConfiguredHint": "Подключите SMS-провайдера, чтобы отправлять и получать сообщения здесь. Запланированная почта работает и без этого.",

+ 26 - 0
messages/ru/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Переписки",
+    "searchPlaceholder": "Поиск по перепискам",
+    "noSearchResults": "Переписок по запросу не найдено.",
+    "empty": "Переписок пока нет.",
+    "selectConversation": "Выберите переписку",
+    "back": "Назад",
+    "newMessage": "Новое сообщение",
+    "unknownContact": "Этот номер пока не привязан к клиенту. Добавьте его как клиента, чтобы ответить.",
+    "noChannelsTitle": "Канал сообщений не настроен",
+    "noChannelsHint": "Подключите SMS, WhatsApp или Telegram, чтобы писать клиентам отсюда.",
+    "goToProviders": "Настроить провайдера",
+    "chooseChannel": {
+      "title": "По какому каналу отправить?",
+      "description": "У вас несколько способов связаться с клиентами."
+    },
+    "deleteConversation": "Удалить переписку",
+    "deleteTitle": "Удалить эту переписку?",
+    "deleteDescription": "Удаляет вашу копию переписки с {name}. На телефоне клиента сообщения останутся.",
+    "deleteFailed": "Не удалось удалить переписку",
+    "delete": "Удалить",
+    "cancel": "Отмена",
+    "loadMore": "Загрузить ещё"
+  }
+}

+ 4 - 2
messages/ru/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Все комплекты",
     "tireStorage": "Структура склада",
     "tireSetDetails": "Комплект шин",
-    "tireForecast": "Следующий сезон"
+    "tireForecast": "Следующий сезон",
+    "whatsapp": "WhatsApp"
   },
   "search": "Поиск...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Ещё",
     "auditLog": "Журнал аудита",
     "aiAssistant": "ИИ-ассистент",
-    "tireHotel": "Шинный отель"
+    "tireHotel": "Шинный отель",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/ru/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "Эл. почта",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Заметка в приложении"
+      "in_app": "Заметка в приложении",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Повтор",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Запланировать",
     "scheduled": "Сообщение запланировано",
     "updated": "Запланированное сообщение обновлено",
-    "saveError": "Не удалось сохранить сообщение"
+    "saveError": "Не удалось сохранить сообщение",
+    "whatsappNeedsTemplate": "Сообщение уйдёт значительно позже закрытия 24-часового окна, а текстовый шаблон WhatsApp не настроен, поэтому отправка не удастся. Настройте его в Настройки > Провайдеры > WhatsApp."
   },
   "list": {
     "scheduleMessage": "Запланировать сообщение",

+ 30 - 0
messages/ru/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Шинный отель",
         "description": "Сезонное хранение шин"
+      },
+      "providers": {
+        "title": "Провайдеры",
+        "description": "Эл. почта, SMS, WhatsApp и Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Цены на подготовку",
     "prepHint": "Сколько стоит каждый вид подготовки. Пустое поле означает, что она никогда не выставляется, как нужно мастерским, у которых мойка входит в хранение. Те, у которых есть цена, попадают в смету и заказ сами.",
     "prepNotCharged": "не выставляется"
+  },
+  "providers": {
+    "tabs": {
+      "email": "Эл. почта",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "Настройки почты",
+        "description": "Настройте отправку по SMTP, чтобы счета, сметы и уведомления уходили прямо из мастерской."
+      },
+      "sms": {
+        "feature": "SMS-сообщения",
+        "description": "Отправляйте и получайте SMS от клиентов прямо в Torqvoice."
+      },
+      "whatsapp": {
+        "feature": "Сообщения WhatsApp",
+        "description": "Отправляйте и получайте сообщения WhatsApp, включая фото деталей, прямо в Torqvoice."
+      },
+      "telegram": {
+        "feature": "Сообщения Telegram",
+        "description": "Отправляйте и получайте сообщения Telegram от клиентов прямо в Torqvoice."
+      }
+    }
   }
 }

+ 179 - 0
messages/ru/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Отправляйте и получайте сообщения WhatsApp от клиентов.",
+    "helpLink": "Настроить {provider}",
+    "save": "Сохранить настройки WhatsApp",
+    "saved": "Настройки WhatsApp сохранены",
+    "saveError": "Не удалось сохранить настройки WhatsApp",
+    "enable": {
+      "label": "Включить WhatsApp",
+      "hint": "Включите, когда данные провайдера заполнены.",
+      "disabledInfo": "Включите WhatsApp, чтобы настроить провайдера, номер и шаблон."
+    },
+    "provider": {
+      "label": "Провайдер",
+      "hint": "Кто предоставляет ваш номер WhatsApp. При смене данные прежнего сохраняются."
+    },
+    "from": {
+      "label": "Ваш номер WhatsApp",
+      "hint": "Номер, который видят клиенты, в международном формате."
+    },
+    "secret": {
+      "show": "Показать",
+      "hide": "Скрыть",
+      "stored": "Сохранено и скрыто. Введите новое значение, чтобы заменить."
+    },
+    "webhook": {
+      "label": "URL вебхука",
+      "description": "Вставьте его у провайдера, чтобы входящие сообщения приходили в Torqvoice.",
+      "copy": "Копировать URL вебхука",
+      "copied": "URL вебхука скопирован",
+      "saveFirst": "Сохраните эти настройки до того, как провайдер проверит URL. Проверка идёт по сохранённым данным, поэтому несохранённая форма её не пройдёт."
+    },
+    "template": {
+      "title": "Шаблон сообщения",
+      "description": "Используется, когда вы пишете клиенту, который давно не отвечал.",
+      "windowExplainer": "WhatsApp разрешает свободный текст только 24 часа после последнего сообщения клиента. Вне этого окна сообщение должно уйти шаблоном, одобренным провайдером.",
+      "nameLabel": "Название шаблона",
+      "nameHint": "Название одобренного шаблона или Content SID у провайдеров, которые его используют.",
+      "languageLabel": "Язык шаблона",
+      "languageHint": "Код языка, под которым шаблон одобрен, например ru.",
+      "variablesLabel": "Значения шаблона по порядку",
+      "variablesHint": "Выберите значения для пронумерованных мест в шаблоне, в том же порядке. Оставьте пустым, если мест нет.",
+      "tokens": {
+        "customer": "имя клиента",
+        "vehicle": "год, марка и модель",
+        "plate": "госномер",
+        "message": "ваш текст",
+        "workshop": "название мастерской",
+        "photo": "прикреплённое фото"
+      },
+      "variablesEmpty": "Значений пока нет. Добавьте их ниже в том порядке, который ожидает шаблон.",
+      "variablesRemove": "Убрать {name}",
+      "mediaUrlLabel": "URL медиа для вашего шаблона",
+      "mediaUrlHint": "Вставьте это в поле медиа в шаблоне и замените n на номер значения с фото.",
+      "mediaUrlCopy": "Копировать URL медиа",
+      "mediaUrlCopied": "URL медиа скопирован",
+      "mediaUrlSample": "В качестве примера значения у провайдера используйте: sample",
+      "textTitle": "Текстовые сообщения",
+      "textDescription": "Используется, когда вы пишете без фото: из Сообщений, со вкладки WhatsApp у клиента или из запланированного сообщения.",
+      "mediaTitle": "Сообщения с фото",
+      "mediaDescription": "Используется кнопкой «Отправить в WhatsApp» на фотографиях заказа и всякий раз, когда вы прикрепляете фото в переписке.",
+      "textMissing": "Не настроено: вы не сможете написать клиенту, который не писал последние 24 часа.",
+      "mediaMissing": "Не настроено: вы не сможете отправить фото клиенту, который не писал последние 24 часа.",
+      "whatIsIt": "Шаблон — это фиксированное сообщение, которое вы пишете один раз и которое одобряет {provider}: формулировка не меняется, вы заполняете только пронумерованные места. Напишите его в их консоли, а сюда вставьте идентификатор.",
+      "whyTwo": "Нужны два, потому что WhatsApp фиксирует тип медиа при одобрении. Шаблон, одобренный с фото, может нести только фото, а одобренный как текст — никогда.",
+      "exampleLabel": "Пример текста для консоли провайдера",
+      "exampleTextValues": "Заполните: customer, message. Пишите на языке ваших клиентов.",
+      "exampleMediaValues": "Заполните: customer, message и photo для URL медиа. Пишите на языке ваших клиентов.",
+      "exampleMediaValuesHeader": "Заполните её значениями: customer, message. Фотография прикрепляется автоматически, поэтому отдельный пропуск ей не нужен. Пишите на том языке, на котором читают ваши клиенты."
+    },
+    "test": {
+      "send": "Отправить тест",
+      "sent": "Тестовое сообщение отправлено",
+      "failed": "Не удалось отправить тестовое сообщение",
+      "hint": "Сначала напишите себе в WhatsApp, иначе тесту понадобится одобренный шаблон."
+    },
+    "disconnect": {
+      "action": "Отключить WhatsApp",
+      "hint": "Удаляет данные доступа из Torqvoice. Переписка сохраняется.",
+      "done": "WhatsApp отключён",
+      "title": "Отключить"
+    },
+    "savedIncomplete": "Сохранено. Для отправки ещё нужно: {fields}",
+    "fieldStillNeeded": "Ещё нужно для отправки",
+    "steps": {
+      "openProvider": "Открыть {provider}",
+      "openNumber": "Найти номер отправителя",
+      "openToken": "Создать постоянный токен",
+      "consolePath": "В Meta перейдите:",
+      "provider": {
+        "title": "Выберите провайдера и номер",
+        "description": "С Twilio старт быстрее, и он берёт Meta на себя. Meta Cloud API дешевле, но верификацию бизнеса вы проходите сами. Укажите номер, который увидят клиенты, в международном формате."
+      },
+      "credentials": {
+        "title": "Введите учётные данные",
+        "description": "Скопируйте каждое значение из {provider}. Каждое поле подсказывает, где его взять. Сохраните, когда они будут; за остальным можно вернуться."
+      },
+      "webhook": {
+        "title": "Зарегистрируйте вебхук",
+        "description": "Вставьте этот адрес в {provider}, чтобы сообщения клиентов доходили до Torqvoice. Сначала сохраните страницу: адрес проверяется по сохранённым здесь данным.",
+        "open": "Открыть настройки вебхука в {provider}",
+        "recheck": "Проверить снова",
+        "waiting": "На этот адрес пока ничего не приходило. Он станет зелёным, как только провайдер его подтвердит или доставит сообщение.",
+        "seen": "Провайдер достучался до этого адреса. Входящие сообщения появятся в разделе Сообщения."
+      },
+      "templates": {
+        "title": "Одобрите шаблоны",
+        "description": "Нужны только чтобы написать первым вне 24-часового окна. Можно пропустить и вернуться, когда переписка заработает.",
+        "open": "Открыть шаблоны {provider}"
+      },
+      "test": {
+        "title": "Отправьте тестовое сообщение",
+        "description": "Сначала напишите на свой номер WhatsApp со своего телефона. Это докажет, что вебхук работает, и откроет 24-часовое окно, так что ответу шаблон не нужен."
+      },
+      "back": "Назад",
+      "continue": "Далее"
+    },
+    "register": {
+      "title": "Зарегистрируйте номер в Meta",
+      "description": "После подтверждения номера Meta требует отдельной регистрации. Выберите шестизначный PIN или используйте существующий PIN двухэтапной проверки. Запишите его: он понадобится, чтобы перенести или заново зарегистрировать номер.",
+      "action": "Зарегистрировать номер",
+      "done": "Номер зарегистрирован",
+      "failed": "Не удалось зарегистрировать номер",
+      "limit": "Meta разрешает 10 попыток регистрации номера за 72 часа. Неверный PIN тоже считается, поэтому проверьте его заранее."
+    }
+  },
+  "messages": {
+    "featureName": "Сообщения WhatsApp",
+    "featureDescription": "Отправляйте и получайте сообщения WhatsApp, включая фото деталей, прямо в Torqvoice.",
+    "noThreads": "Переписок пока нет",
+    "noThreadsHint": "Сообщения появятся здесь, как только клиент напишет на ваш номер WhatsApp или вы напишете ему.",
+    "selectThread": "Выберите переписку",
+    "backToThreads": "Переписки",
+    "empty": "Здесь пока пусто.",
+    "placeholder": "Напишите сообщение",
+    "send": "Отправить",
+    "sendFailed": "Не удалось отправить сообщение",
+    "attach": "Прикрепить фото",
+    "attachFailed": "Не удалось прикрепить фото",
+    "removeAttachment": "Убрать вложение",
+    "attachment": "Вложение",
+    "delete": "Удалить",
+    "deleteFailed": "Не удалось удалить сообщение",
+    "sentAsTemplate": "шаблон",
+    "sentAsTemplateNotice": "Отправлено вашим одобренным шаблоном, потому что клиент не писал последние 24 часа.",
+    "windowClosed": "Клиент не писал последние 24 часа, поэтому сообщение уйдёт вашим одобренным шаблоном.",
+    "unknownNumber": "Этот номер пока не привязан к клиенту. Добавьте его как клиента, чтобы ответить.",
+    "notConfigured": {
+      "title": "WhatsApp не подключён",
+      "description": "Подключите номер WhatsApp, чтобы переписываться с клиентами.",
+      "action": "Настроить WhatsApp"
+    },
+    "title": "Переписки",
+    "searchPlaceholder": "Поиск по перепискам",
+    "noSearchResults": "Переписок по запросу не найдено.",
+    "compose": {
+      "newMessage": "Новое сообщение",
+      "title": "Новое сообщение WhatsApp",
+      "searchPlaceholder": "Поиск клиентов",
+      "noMatches": "Клиентов по запросу не найдено.",
+      "noRecipients": "Пока нет клиентов с номером телефона."
+    },
+    "windowBlocked": "Этот клиент не писал последние 24 часа, поэтому WhatsApp сейчас не доставит сообщение. Попросите его написать вам или настройте одобренный шаблон.",
+    "windowBlockedAction": "Настроить шаблон",
+    "windowBlockedPlaceholder": "Ждём первого сообщения от клиента"
+  },
+  "sendPhoto": {
+    "action": "Отправить в WhatsApp",
+    "title": "Отправить фото в WhatsApp",
+    "description": "Клиент получит это фото в своём чате WhatsApp.",
+    "captionPlaceholder": "Добавьте пояснение, например что на фото",
+    "send": "Отправить",
+    "cancel": "Отмена",
+    "sent": "Фото отправлено",
+    "sentAsTemplate": "Фото отправлено вашим одобренным шаблоном",
+    "failed": "Не удалось отправить фото"
+  }
+}

+ 4 - 2
messages/tr/audit.json

@@ -135,7 +135,8 @@
     "tire_set_return": "Takım yeniden depolandı",
     "tire_set_dispose": "Lastik takımı hurdaya ayrıldı",
     "tire_set_attach": "Lastik takımına dosya eklendi",
-    "tire_set_detach": "Lastik takımından dosya kaldırıldı"
+    "tire_set_detach": "Lastik takımından dosya kaldırıldı",
+    "whatsapp_send": "WhatsApp mesajı gönderildi"
   },
   "summary": {
     "customField_create": "\"{name}\" özel alanı oluşturuldu",
@@ -254,6 +255,7 @@
     "vehicle_unarchive": "{id} aracı arşivden çıkarıldı",
     "vehicle_update": "{name} aracı güncellendi, değişenler: {fields}",
     "vehicle_updateNoChanges": "{name} aracı güncellendi, hiçbir şey değişmedi",
-    "webhook_create": "{name} webhook'u oluşturuldu"
+    "webhook_create": "{name} webhook'u oluşturuldu",
+    "whatsapp_send": "{name} kişisine WhatsApp mesajı gönderildi"
   }
 }

+ 2 - 1
messages/tr/customers.json

@@ -38,7 +38,8 @@
       "messages": "Mesajlar",
       "requests": "Talepler ({count})",
       "sms": "SMS",
-      "telegram": "Telegram"
+      "telegram": "Telegram",
+      "whatsapp": "WhatsApp"
     },
     "vehicleTable": {
       "plate": "Plaka",

+ 4 - 1
messages/tr/messages.json

@@ -4,7 +4,10 @@
     "featureDescription": "Müşterilerinizle doğrudan Torqvoice üzerinden SMS mesajları gönderin ve alın.",
     "tabs": {
       "inbox": "Gelen kutusu",
-      "scheduled": "Planlanmış"
+      "scheduled": "Planlanmış",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
     },
     "smsNotConfiguredTitle": "SMS kurulmadı",
     "smsNotConfiguredHint": "Burada mesaj gönderip almak için bir SMS sağlayıcısı bağlayın. Planlanmış e-posta bunsuz da çalışır.",

+ 26 - 0
messages/tr/messaging.json

@@ -0,0 +1,26 @@
+{
+  "inbox": {
+    "title": "Görüşmeler",
+    "searchPlaceholder": "Görüşmelerde ara",
+    "noSearchResults": "Aramanızla eşleşen görüşme yok.",
+    "empty": "Henüz görüşme yok.",
+    "selectConversation": "Bir görüşme seçin",
+    "back": "Geri",
+    "newMessage": "Yeni mesaj",
+    "unknownContact": "Bu numara henüz bir müşteriye ait değil. Yanıtlamak için müşteri olarak ekleyin.",
+    "noChannelsTitle": "Kurulu mesajlaşma kanalı yok",
+    "noChannelsHint": "Buradan müşterilerinize yazmak için SMS, WhatsApp veya Telegram bağlayın.",
+    "goToProviders": "Sağlayıcı kur",
+    "chooseChannel": {
+      "title": "Hangi kanaldan gönderilsin?",
+      "description": "Müşterilerinize ulaşmanın birden fazla yolu var."
+    },
+    "deleteConversation": "Görüşmeyi sil",
+    "deleteTitle": "Bu görüşme silinsin mi?",
+    "deleteDescription": "{name} ile görüşmenin sizdeki kopyasını siler. Müşterinin telefonunda mesajlar kalır.",
+    "deleteFailed": "Görüşme silinemedi",
+    "delete": "Sil",
+    "cancel": "İptal",
+    "loadMore": "Daha fazla yükle"
+  }
+}

+ 4 - 2
messages/tr/navigation.json

@@ -58,7 +58,8 @@
     "allTireSets": "Tüm lastik takımları",
     "tireStorage": "Depo düzeni",
     "tireSetDetails": "Lastik takımı",
-    "tireForecast": "Gelecek sezon"
+    "tireForecast": "Gelecek sezon",
+    "whatsapp": "WhatsApp"
   },
   "search": "Ara...",
   "shortcut": "Ctrl+K",
@@ -113,6 +114,7 @@
     "more": "Diğer",
     "auditLog": "Denetim günlüğü",
     "aiAssistant": "AI Asistanı",
-    "tireHotel": "Lastik oteli"
+    "tireHotel": "Lastik oteli",
+    "whatsapp": "WhatsApp"
   }
 }

+ 4 - 2
messages/tr/scheduledMessages.json

@@ -7,7 +7,8 @@
       "email": "E-posta",
       "sms": "SMS",
       "telegram": "Telegram",
-      "in_app": "Uygulama içi not"
+      "in_app": "Uygulama içi not",
+      "whatsapp": "WhatsApp"
     },
     "repeatLabel": "Tekrar",
     "frequencies": {
@@ -39,7 +40,8 @@
     "schedule": "Planla",
     "scheduled": "Mesaj planlandı",
     "updated": "Planlanmış mesaj güncellendi",
-    "saveError": "Mesaj kaydedilemedi"
+    "saveError": "Mesaj kaydedilemedi",
+    "whatsappNeedsTemplate": "Bu mesaj müşterinin 24 saatlik penceresi kapandıktan çok sonra gidecek ve bir WhatsApp metin şablonu kurulu değil, bu yüzden gönderimde başarısız olur. Ayarlar > Sağlayıcılar > WhatsApp altından bir tane kurun."
   },
   "list": {
     "scheduleMessage": "Mesaj planla",

+ 30 - 0
messages/tr/settings.json

@@ -126,6 +126,10 @@
       "tireHotel": {
         "title": "Lastik oteli",
         "description": "Mevsimlik lastik saklama"
+      },
+      "providers": {
+        "title": "Sağlayıcılar",
+        "description": "E-posta, SMS, WhatsApp ve Telegram"
       }
     },
     "pro": "PRO"
@@ -1487,5 +1491,31 @@
     "prepTitle": "Hazırlık fiyatları",
     "prepHint": "Her hazırlık türünün ücreti. Boş bırakırsanız hiç faturalandırılmaz; yıkamayı depolama ücretine dahil eden atölyeler için budur. Fiyatı olanlar teklife ve iş emrine kendiliğinden eklenir.",
     "prepNotCharged": "ücretlendirilmiyor"
+  },
+  "providers": {
+    "tabs": {
+      "email": "E-posta",
+      "sms": "SMS",
+      "whatsapp": "WhatsApp",
+      "telegram": "Telegram"
+    },
+    "locked": {
+      "email": {
+        "feature": "E-posta ayarları",
+        "description": "Faturaların, tekliflerin ve bildirimlerin doğrudan atölyeden gitmesi için SMTP gönderimini ayarlayın."
+      },
+      "sms": {
+        "feature": "SMS mesajlaşma",
+        "description": "Müşterilerinizle SMS'i doğrudan Torqvoice üzerinden gönderip alın."
+      },
+      "whatsapp": {
+        "feature": "WhatsApp mesajlaşma",
+        "description": "Parça fotoğrafları dahil WhatsApp mesajlarını doğrudan Torqvoice üzerinden gönderip alın."
+      },
+      "telegram": {
+        "feature": "Telegram mesajlaşma",
+        "description": "Müşterilerinizle Telegram mesajlarını doğrudan Torqvoice üzerinden gönderip alın."
+      }
+    }
   }
 }

+ 179 - 0
messages/tr/whatsapp.json

@@ -0,0 +1,179 @@
+{
+  "settings": {
+    "title": "WhatsApp",
+    "description": "Müşterilerinizle WhatsApp mesajları gönderip alın.",
+    "helpLink": "{provider} kurulumu",
+    "save": "WhatsApp ayarlarını kaydet",
+    "saved": "WhatsApp ayarları kaydedildi",
+    "saveError": "WhatsApp ayarları kaydedilemedi",
+    "enable": {
+      "label": "WhatsApp'ı etkinleştir",
+      "hint": "Sağlayıcı bilgileri girildikten sonra açın.",
+      "disabledInfo": "Sağlayıcı, numara ve şablonu ayarlamak için WhatsApp'ı açın."
+    },
+    "provider": {
+      "label": "Sağlayıcı",
+      "hint": "WhatsApp numaranızı kim sağlıyor. Değiştirdiğinizde eskisinin bilgileri korunur."
+    },
+    "from": {
+      "label": "WhatsApp numaranız",
+      "hint": "Müşterilerin gördüğü numara, uluslararası biçimde."
+    },
+    "secret": {
+      "show": "Göster",
+      "hide": "Gizle",
+      "stored": "Kaydedildi ve gizlendi. Değiştirmek için yeni bir değer yazın."
+    },
+    "webhook": {
+      "label": "Webhook adresi",
+      "description": "Gelen mesajların Torqvoice'a ulaşması için bunu sağlayıcınıza yapıştırın.",
+      "copy": "Webhook adresini kopyala",
+      "copied": "Webhook adresi kopyalandı",
+      "saveFirst": "Sağlayıcınız adresi doğrulamadan önce bu ayarları kaydedin. Doğrulama burada kayıtlı olana göre yapılır, kaydedilmemiş form geçemez."
+    },
+    "template": {
+      "title": "Mesaj şablonu",
+      "description": "Size yakın zamanda yazmamış bir müşteriye yazdığınızda kullanılır.",
+      "windowExplainer": "WhatsApp serbest metne yalnızca müşterinin son mesajından sonraki 24 saat boyunca izin verir. Bu pencerenin dışında mesajınız sağlayıcınızın onayladığı bir şablon olarak gitmek zorundadır.",
+      "nameLabel": "Şablon adı",
+      "nameHint": "Onaylı şablonun adı veya bunu kullanan sağlayıcılarda Content SID.",
+      "languageLabel": "Şablon dili",
+      "languageHint": "Şablonun onaylandığı dil kodu, örneğin tr.",
+      "variablesLabel": "Şablon değerleri, sırayla",
+      "variablesHint": "Şablonunuzdaki numaralı boşlukları dolduran değerleri aynı sırayla seçin. Şablonunuzda boşluk yoksa boş bırakın.",
+      "tokens": {
+        "customer": "müşteri adı",
+        "vehicle": "yıl, marka ve model",
+        "plate": "plaka",
+        "message": "yazdığınız metin",
+        "workshop": "atölyenizin adı",
+        "photo": "eklenen fotoğraf"
+      },
+      "variablesEmpty": "Henüz değer yok. Şablonunuzun beklediği sırayla aşağıdan ekleyin.",
+      "variablesRemove": "{name} kaldır",
+      "mediaUrlLabel": "Şablonunuz için medya adresi",
+      "mediaUrlHint": "Bunu şablonun medya alanına yapıştırın ve n yerine fotoğraf değerinin numarasını yazın.",
+      "mediaUrlCopy": "Medya adresini kopyala",
+      "mediaUrlCopied": "Medya adresi kopyalandı",
+      "mediaUrlSample": "Sağlayıcıdaki örnek değer olarak şunu kullanın: sample",
+      "textTitle": "Metin mesajları",
+      "textDescription": "Fotoğrafsız yazdığınızda kullanılır: Mesajlar'dan, müşterinin WhatsApp sekmesinden veya planlanmış bir mesajdan.",
+      "mediaTitle": "Fotoğraflı mesajlar",
+      "mediaDescription": "Bir işin fotoğraflarındaki WhatsApp'tan gönder düğmesi ve bir görüşmede fotoğraf eklediğiniz her durumda kullanılır.",
+      "textMissing": "Kurulmadı: son 24 saatte yazmamış bir müşteriye yazamazsınız.",
+      "mediaMissing": "Kurulmadı: son 24 saatte yazmamış bir müşteriye fotoğraf gönderemezsiniz.",
+      "whatIsIt": "Şablon, bir kez yazdığınız ve {provider} tarafından onaylanan sabit bir mesajdır: metin hiç değişmez, yalnızca numaralı boşlukları doldurursunuz. Onların konsolunda yazın, tanımlayıcısını buraya yapıştırın.",
+      "whyTwo": "İki tane gerekir çünkü WhatsApp medya türünü onay anında sabitler. Fotoğrafla onaylanan bir şablon yalnızca fotoğraf taşır, metin olarak onaylanan ise hiç taşıyamaz.",
+      "exampleLabel": "Sağlayıcınızın konsoluna yazacağınız örnek metin",
+      "exampleTextValues": "Şunlarla doldurun: customer, message. Müşterilerinizin okuduğu dilde yazın.",
+      "exampleMediaValues": "Şunlarla doldurun: customer, message ve medya adresi için photo. Müşterilerinizin okuduğu dilde yazın.",
+      "exampleMediaValuesHeader": "Şununla doldurun: customer, message. Fotoğraf otomatik olarak eklenir, bu yüzden kendine ait bir boşluğa gerek duymaz. Müşterilerinizin okuduğu dilde yazın."
+    },
+    "test": {
+      "send": "Test gönder",
+      "sent": "Test mesajı gönderildi",
+      "failed": "Test mesajı gönderilemedi",
+      "hint": "Önce WhatsApp'tan kendinize yazın, aksi halde test onaylı bir şablon ister."
+    },
+    "disconnect": {
+      "action": "WhatsApp bağlantısını kes",
+      "hint": "Kimlik bilgilerini Torqvoice'tan siler. Görüşmeleriniz kalır.",
+      "done": "WhatsApp bağlantısı kesildi",
+      "title": "Bağlantıyı kes"
+    },
+    "savedIncomplete": "Kaydedildi. Gönderebilmek için hâlâ gerekli: {fields}",
+    "fieldStillNeeded": "Göndermek için hâlâ gerekli",
+    "steps": {
+      "openProvider": "{provider} aç",
+      "openNumber": "Gönderici numaranızı bulun",
+      "openToken": "Kalıcı bir token oluşturun",
+      "consolePath": "Meta'da şuraya gidin:",
+      "provider": {
+        "title": "Sağlayıcınızı ve numaranızı seçin",
+        "description": "Twilio ile başlamak daha hızlıdır ve Meta tarafını sizin için üstlenir. Meta Cloud API daha ucuzdur ama işletme doğrulamasını siz tamamlarsınız. Müşterilerin göreceği numarayı uluslararası biçimde girin."
+      },
+      "credentials": {
+        "title": "Kimlik bilgilerinizi girin",
+        "description": "Her değeri {provider} üzerinden kopyalayın. Aşağıdaki her alan nerede bulunacağını söyler. Elinizdekileri kaydedin; kalanı için geri dönebilirsiniz."
+      },
+      "webhook": {
+        "title": "Webhook'u kaydedin",
+        "description": "Müşteri mesajlarının Torqvoice'a ulaşması için bu adresi {provider} tarafına yapıştırın. Önce bu sayfayı kaydedin: adres burada kayıtlı olana göre denetlenir.",
+        "open": "{provider} webhook ayarlarını aç",
+        "recheck": "Yeniden denetle",
+        "waiting": "Bu adrese henüz bir şey ulaşmadı. Sağlayıcınız doğruladığı ya da mesaj ilettiği anda yeşile döner.",
+        "seen": "Sağlayıcınız bu adrese ulaştı. Gelen mesajlar Mesajlar'da görünecek."
+      },
+      "templates": {
+        "title": "Şablonlarınızı onaylatın",
+        "description": "Yalnızca 24 saatlik pencerenin dışında ilk yazan taraf olmak için gerekir. Şimdilik atlayıp mesajlaşma çalıştığında dönebilirsiniz.",
+        "open": "{provider} şablonlarını aç"
+      },
+      "test": {
+        "title": "Test mesajı gönderin",
+        "description": "Önce kendi telefonunuzdan WhatsApp numaranıza yazın. Bu, webhook'un çalıştığını kanıtlar ve 24 saatlik pencereyi açar; böylece yanıt için şablon gerekmez."
+      },
+      "back": "Geri",
+      "continue": "Devam"
+    },
+    "register": {
+      "title": "Numarayı Meta'ya kaydedin",
+      "description": "Numara doğrulandıktan sonra Meta ayrı bir kayıt ister. Altı haneli bir PIN seçin ya da numaranın mevcut iki adımlı doğrulama PIN'ini kullanın. Not edin: numarayı taşırken veya yeniden kaydederken gerekir.",
+      "action": "Numarayı kaydet",
+      "done": "Numara kaydedildi",
+      "failed": "Numara kaydedilemedi",
+      "limit": "Meta bir numara için 72 saatte 10 kayıt denemesine izin verir. Yanlış PIN de sayılır, önce kontrol edin."
+    }
+  },
+  "messages": {
+    "featureName": "WhatsApp mesajlaşma",
+    "featureDescription": "Parça fotoğrafları dahil WhatsApp mesajlarını doğrudan Torqvoice üzerinden gönderip alın.",
+    "noThreads": "Henüz görüşme yok",
+    "noThreadsHint": "Bir müşteri WhatsApp numaranıza yazdığında ya da siz ona yazdığınızda mesajlar burada belirir.",
+    "selectThread": "Bir görüşme seçin",
+    "backToThreads": "Görüşmeler",
+    "empty": "Burada henüz bir şey yok.",
+    "placeholder": "Bir mesaj yazın",
+    "send": "Gönder",
+    "sendFailed": "Mesaj gönderilemedi",
+    "attach": "Fotoğraf ekle",
+    "attachFailed": "Fotoğraf eklenemedi",
+    "removeAttachment": "Eki kaldır",
+    "attachment": "Ek",
+    "delete": "Sil",
+    "deleteFailed": "Mesaj silinemedi",
+    "sentAsTemplate": "şablon",
+    "sentAsTemplateNotice": "Müşteri son 24 saatte yazmadığı için onaylı şablonunuzla gönderildi.",
+    "windowClosed": "Müşteri son 24 saatte yazmadı, bu yüzden mesaj onaylı şablonunuzla gidecek.",
+    "unknownNumber": "Bu numara henüz bir müşteriye ait değil. Yanıtlamak için müşteri olarak ekleyin.",
+    "notConfigured": {
+      "title": "WhatsApp bağlı değil",
+      "description": "Müşterilerinizle mesajlaşmak için bir WhatsApp numarası bağlayın.",
+      "action": "WhatsApp'ı kur"
+    },
+    "title": "Görüşmeler",
+    "searchPlaceholder": "Görüşmelerde ara",
+    "noSearchResults": "Aramanızla eşleşen görüşme yok.",
+    "compose": {
+      "newMessage": "Yeni mesaj",
+      "title": "Yeni WhatsApp mesajı",
+      "searchPlaceholder": "Müşteri ara",
+      "noMatches": "Aramanızla eşleşen müşteri yok.",
+      "noRecipients": "Henüz telefon numarası olan müşteri yok."
+    },
+    "windowBlocked": "Bu müşteri son 24 saatte yazmadı, bu yüzden WhatsApp şu anda mesaj iletmez. Size yazmasını isteyin ya da onaylı bir şablon kurun.",
+    "windowBlockedAction": "Şablon kur",
+    "windowBlockedPlaceholder": "Müşterinin ilk yazması bekleniyor"
+  },
+  "sendPhoto": {
+    "action": "WhatsApp'tan gönder",
+    "title": "Fotoğrafı WhatsApp'tan gönder",
+    "description": "Müşteri bu fotoğrafı WhatsApp sohbetinde alır.",
+    "captionPlaceholder": "Bir not ekleyin, örneğin fotoğrafta ne olduğunu",
+    "send": "Gönder",
+    "cancel": "İptal",
+    "sent": "Fotoğraf gönderildi",
+    "sentAsTemplate": "Fotoğraf onaylı şablonunuzla gönderildi",
+    "failed": "Fotoğraf gönderilemedi"
+  }
+}

+ 39 - 0
prisma/migrations/20260821134531_whatsapp_messages/migration.sql

@@ -0,0 +1,39 @@
+-- CreateTable
+CREATE TABLE "whatsapp_messages" (
+    "id" TEXT NOT NULL,
+    "direction" TEXT NOT NULL,
+    "provider" TEXT NOT NULL,
+    "fromNumber" TEXT NOT NULL,
+    "toNumber" TEXT NOT NULL,
+    "body" TEXT,
+    "mediaUrl" TEXT,
+    "mediaType" TEXT,
+    "mediaFilename" TEXT,
+    "templateName" TEXT,
+    "status" TEXT NOT NULL DEFAULT 'queued',
+    "providerMessageId" TEXT,
+    "errorMessage" TEXT,
+    "relatedEntityType" TEXT,
+    "relatedEntityId" TEXT,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL,
+    "organizationId" TEXT NOT NULL,
+    "customerId" TEXT,
+
+    CONSTRAINT "whatsapp_messages_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "whatsapp_messages_organizationId_customerId_createdAt_idx" ON "whatsapp_messages"("organizationId", "customerId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "whatsapp_messages_organizationId_createdAt_idx" ON "whatsapp_messages"("organizationId", "createdAt" DESC);
+
+-- CreateIndex
+CREATE INDEX "whatsapp_messages_providerMessageId_idx" ON "whatsapp_messages"("providerMessageId");
+
+-- AddForeignKey
+ALTER TABLE "whatsapp_messages" ADD CONSTRAINT "whatsapp_messages_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "whatsapp_messages" ADD CONSTRAINT "whatsapp_messages_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE;

+ 51 - 0
prisma/schema.prisma

@@ -405,6 +405,7 @@ model Customer {
   quotes            Quote[]
   smsMessages       SmsMessage[]
   telegramMessages  TelegramMessage[]
+  whatsappMessages  WhatsappMessage[]
   customerSessions  CustomerSession[]
   scheduledMessages ScheduledMessage[]
   serviceRequests   ServiceRequest[]
@@ -832,6 +833,7 @@ model Organization {
   inspectionTemplates    InspectionTemplate[]
   smsMessages            SmsMessage[]
   telegramMessages       TelegramMessage[]
+  whatsappMessages       WhatsappMessage[]
   technicians            Technician[]
   auditLogs              AuditLog[]
   statusReports          StatusReport[]
@@ -1277,6 +1279,55 @@ model TelegramMessage {
   @@map("telegram_messages")
 }
 
+/// One WhatsApp message in either direction.
+///
+/// `provider` records which adapter carried it, because a workshop can switch
+/// provider without its history becoming unreadable, and a status callback
+/// arriving later has to find the row it belongs to.
+model WhatsappMessage {
+  id        String  @id @default(cuid())
+  direction String // "inbound" | "outbound"
+  provider  String // "meta" | "twilio" | any adapter registered later
+
+  /// E.164 without the "whatsapp:" prefix a provider may use on the wire.
+  fromNumber String
+  toNumber   String
+
+  body String?
+
+  /// Media the message carried. Inbound URLs may need the provider's
+  /// credentials to fetch, so they are not necessarily public.
+  mediaUrl      String?
+  mediaType     String? // image | document | video | audio | sticker
+  mediaFilename String?
+
+  /// Set when the message had to go out as an approved template, which is the
+  /// only thing WhatsApp allows outside the 24 hour service window.
+  templateName String?
+
+  status            String  @default("queued") // queued | sent | delivered | read | failed | received
+  providerMessageId String?
+  errorMessage      String?
+
+  relatedEntityType String?
+  relatedEntityId   String?
+
+  createdAt DateTime @default(now())
+  updatedAt DateTime @updatedAt
+
+  organizationId String
+  organization   Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
+
+  customerId String?
+  customer   Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
+
+  @@index([organizationId, customerId, createdAt(sort: Desc)])
+  @@index([organizationId, createdAt(sort: Desc)])
+  @@index([providerMessageId])
+  @@map("whatsapp_messages")
+}
+
+
 model Technician {
   id            String  @id @default(cuid())
   name          String

+ 73 - 0
src/__tests__/features/messaging/inbox-paging.test.ts

@@ -0,0 +1,73 @@
+/**
+ * Tests for merging three channels into one paginated list.
+ *
+ * Pagination here is keyset, across sources that are queried separately, which
+ * is where conversations go missing quietly: a wrong cursor drops everything
+ * older than the shortest channel, and a cursor that never turns null makes the
+ * list scroll forever. Neither shows up in a database with ten rows.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { mergeChannelPages } from '@/features/messaging/Lib/mergeChannelPages'
+import type { InboxThread } from '@/features/messaging/Actions/inboxActions'
+
+function thread(channel: InboxThread['channel'], minutesAgo: number): InboxThread {
+  const at = new Date(Date.UTC(2026, 7, 21, 12, 0, 0) - minutesAgo * 60_000)
+  return {
+    key: `${channel}:${minutesAgo}`,
+    channel,
+    customerId: `c${minutesAgo}`,
+    name: `Customer ${minutesAgo}`,
+    contact: '+4915112345678',
+    lastMessage: 'hello',
+    lastDirection: 'inbound',
+    lastAt: at.toISOString(),
+  }
+}
+
+describe('mergeChannelPages', () => {
+  it('interleaves the channels by recency rather than grouping them', () => {
+    const merged = mergeChannelPages(
+      [[thread('sms', 10), thread('sms', 30)], [thread('whatsapp', 20)], []],
+      10
+    )
+    expect(merged.threads.map((t) => t.channel)).toEqual(['sms', 'whatsapp', 'sms'])
+  })
+
+  it('stops at the page size and continues from the oldest row shown', () => {
+    const merged = mergeChannelPages(
+      [
+        [thread('sms', 1), thread('sms', 4)],
+        [thread('whatsapp', 2), thread('whatsapp', 5)],
+      ],
+      2
+    )
+    expect(merged.threads).toHaveLength(2)
+    expect(merged.nextCursor).toBe(merged.threads[1].lastAt)
+  })
+
+  it('ends the list when every channel came back short', () => {
+    const merged = mergeChannelPages([[thread('sms', 1)], [thread('whatsapp', 2)]], 30)
+    expect(merged.nextCursor).toBeNull()
+  })
+
+  it('keeps paging while one channel filled its page, even if the merge did not', () => {
+    // Two of three channels are exhausted; the third still has more to give.
+    const merged = mergeChannelPages([[thread('sms', 1), thread('sms', 2)], [], []], 2)
+    expect(merged.nextCursor).not.toBeNull()
+  })
+
+  it('has nothing to continue from when there is nothing at all', () => {
+    const merged = mergeChannelPages([[], [], []], 30)
+    expect(merged.threads).toEqual([])
+    expect(merged.nextCursor).toBeNull()
+  })
+
+  it('hands back a cursor strictly older than every row shown', () => {
+    const merged = mergeChannelPages([[thread('sms', 1), thread('sms', 2), thread('sms', 3)]], 2)
+    const shown = merged.threads.map((t) => t.lastAt)
+    // The next page asks for rows older than this, so it must equal the last
+    // row shown: anything newer would skip a conversation.
+    expect(merged.nextCursor).toBe(shown[shown.length - 1])
+  })
+})

+ 50 - 0
src/__tests__/features/messaging/message-text.test.ts

@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { tokenizeMessage } from '@/features/messaging/Lib/messageText'
+
+describe('tokenizeMessage', () => {
+  it('leaves plain text alone', () => {
+    expect(tokenizeMessage('Your car is ready')).toEqual([
+      { type: 'text', value: 'Your car is ready' },
+    ])
+  })
+
+  it('pulls a link out of a sentence', () => {
+    const tokens = tokenizeMessage('Track it at https://example.com/a?b=1 today')
+    expect(tokens).toEqual([
+      { type: 'text', value: 'Track it at ' },
+      { type: 'link', value: 'https://example.com/a?b=1', href: 'https://example.com/a?b=1' },
+      { type: 'text', value: ' today' },
+    ])
+  })
+
+  it('keeps sentence punctuation out of the address', () => {
+    const tokens = tokenizeMessage('See https://example.com/setup.')
+    expect(tokens[1]).toEqual({
+      type: 'link',
+      value: 'https://example.com/setup',
+      href: 'https://example.com/setup',
+    })
+    expect(tokens[2]).toEqual({ type: 'text', value: '.' })
+  })
+
+  it('keeps encoded characters that belong to the address', () => {
+    const url = 'https://business.facebook.com/latest/whatsapp%5Fmanager/setup?a=1&b=2'
+    expect(tokenizeMessage(url)).toEqual([{ type: 'link', value: url, href: url }])
+  })
+
+  it("reads WhatsApp's own emphasis markers", () => {
+    expect(tokenizeMessage('*Continue* setting up')).toEqual([
+      { type: 'text', value: 'Continue', bold: true },
+      { type: 'text', value: ' setting up' },
+    ])
+  })
+
+  it('ignores a marker that wraps nothing', () => {
+    expect(tokenizeMessage('2 * 3 = 6')).toEqual([{ type: 'text', value: '2 * 3 = 6' }])
+  })
+
+  it('never treats another scheme as a link', () => {
+    const tokens = tokenizeMessage('javascript:alert(1) and file:///etc/passwd')
+    expect(tokens.every((token) => token.type === 'text')).toBe(true)
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов