Add contacts views

This commit is contained in:
Nathan Chapman 2024-11-19 09:28:06 -07:00
parent 584dc08292
commit 392536fd7f
20 changed files with 1061 additions and 123 deletions

2
.gitignore vendored
View File

@ -35,3 +35,5 @@ send_it-*.tar
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/
setup_env.sh
customers_master_list.csv

View File

@ -0,0 +1,315 @@
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,700;1,400;1,700&family=IBM+Plex+Sans:ital,wght@0,200;0,400;0,700;1,200;1,400;1,700&family=IBM+Plex+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@import url("./normalize.css");
:root {
/* --blue: #0064ff;*/
--grey: #d8d8d8;
--grey-dark: #1f1d1c;
--red: #ce2b2b;
--blue: #3ab0ed;
--magenta: #9b62fd;
--white: #ffffff;
--black: #000000;
--cyan: #1ee3cf;
--yellow: #eed811;
--pink: #eb0871;
}
html {
font-size: 100%;
}
body {
background: rgb(44, 43, 54);
background: radial-gradient(circle, rgba(44, 43, 54, 1) 25%, rgba(41, 41, 44, 1) 100%);
font-family: 'IBM Plex Sans', sans-serif;
font-weight: 400;
line-height: 1.3;
color: var(--white);
}
p {
margin: 0;
font-size: 1rem;
}
a {
color: var(--magenta);
cursor: pointer;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 700;
line-height: 1;
}
h1 {
margin-top: 0;
font-size: 1.802rem;
}
h2 {
font-size: 1.602rem;
}
h3 {
font-size: 1.424rem;
}
h4 {
font-size: 1.266rem;
}
h5 {
font-size: 1.125rem;
}
h6 {
font-size: 1rem;
}
small {
font-size: 0.889rem;
}
main {
max-width: 1024px;
margin: 0 auto;
padding: 1rem;
}
.table-default {
border-collapse: collapse;
width: 100%;
text-align: left;
margin: 1rem 0;
& thead {
border-bottom: 3px solid var(--magenta);
}
& th,
td {
padding: 0.5rem 1rem;
}
& td.is-link:hover {
cursor: pointer;
}
& tr:nth-child(2n) {
background-color: var(--grey-dark);
}
& tbody tr:hover {
color: white;
background-color: var(--magenta);
& a {
color: white;
}
}
& .table-actions {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
}
@media screen and (max-width:600px) {
.table-default {
& tr {
display: grid;
grid-template-columns: 1fr
}
& tfoot th {
text-align: unset
}
}
}
/* Forms */
label {
font: inherit;
line-height: inherit;
display: inline-block;
}
input[type=datetime-local] {
background-color: inherit;
color: inherit;
outline: none;
line-height: 1.75;
padding: 0.25rem 0.5rem;
font-size: 1.25em;
font-family: "IBM Plex Mono", monospace;
margin: 0;
width: 100%;
border: 3px solid #414141;
}
input,
textarea {
caret-color: var(--magenta);
background-color: inherit;
color: inherit;
outline: none;
line-height: 1.75;
padding: 0.25rem 0.5rem;
font-size: 1.25em;
font-family: "IBM Plex Mono", monospace;
margin: 0;
width: 100%;
resize: vertical;
border: 3px solid #414141;
}
textarea {
min-height: 12rem;
}
input:focus,
input[type=datetime-local]:focus,
textarea:focus,
textarea:focus {
border-color: var(--magenta);
}
button {
color: white;
background-color: var(--magenta);
font-weight: bold;
border: none;
padding: 0.25rem 0.5rem;
margin-bottom: 1rem;
font-size: 1.25em;
cursor: pointer;
}
.form-default {
display: flex;
gap: 1rem;
flex-direction: column;
.form-field {
display: flex;
gap: 0.25rem;
flex-direction: column;
.checkbox {
display: flex;
gap: 0.5rem;
align-items: center;
align-self: flex-start;
}
}
}
input[type="radio"],
input[type="checkbox"] {
width: 2rem;
height: 2rem;
vertical-align: middle;
accent-color: var(--magenta);
}
.flex-column {
display: flex;
gap: 1rem;
flex-direction: column;
}
.header-default {
display: flex;
gap: 1rem;
justify-content: space-between;
}
.site-nav {
padding: 1rem;
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: baseline;
.nav-list {
list-style-type: none;
display: flex;
gap: 1rem;
justify-content: space-between;
align-items: baseline;
}
}
.modal-wrapper,
.modal-bg {
position: fixed;
top: 0;
right: 0;
left: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background: rgba(0, 0, 0, 0.7);
z-index: 9999;
}
.modal {
box-sizing: border-box;
overflow-y: scroll;
background: var(--grey-dark);
margin: auto;
max-height: 100%;
padding: 1rem;
width: calc(72ch + 48px);
border: 0.5rem solid var(--magenta);
.header {
display: grid;
grid-template-columns: 2fr 1fr;
}
.modal-close {
justify-self: end;
}
.modal-container {
display: flex;
flex-direction: column;
}
}
.error {
color: var(--red);
}
.banner {
max-width: 1024px;
margin: 0 auto;
background-color: var(--grey-dark);
padding: 2rem;
border-radius: 1rem;
& h2 {
font-size: 3em;
margin-bottom: 1rem;
}
& p {
font-size: 1.75em;
}
}

227
assets/css/normalize.css vendored Normal file
View File

@ -0,0 +1,227 @@
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
/*
Document
========
*/
/**
Use a better box model (opinionated).
*/
*,
::before,
::after {
box-sizing: border-box;
}
html {
/* Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3) */
font-family:
system-ui,
'Segoe UI',
Roboto,
Helvetica,
Arial,
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji';
line-height: 1.15;
/* 1. Correct the line height in all browsers. */
-webkit-text-size-adjust: 100%;
/* 2. Prevent adjustments of font size after orientation changes in iOS. */
tab-size: 4;
/* 3. Use a more readable tab size (opinionated). */
}
/*
Sections
========
*/
body {
margin: 0;
/* Remove the margin in all browsers. */
}
/*
Text-level semantics
====================
*/
/**
Add the correct font weight in Chrome and Safari.
*/
b,
strong {
font-weight: bolder;
}
/**
1. Improve consistency of default fonts in all browsers. (https://github.com/sindresorhus/modern-normalize/issues/3)
2. Correct the odd 'em' font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family:
ui-monospace,
SFMono-Regular,
Consolas,
'Liberation Mono',
Menlo,
monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/**
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/**
Prevent 'sub' and 'sup' elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
Tabular data
============
*/
/**
Correct table border color inheritance in Chrome and Safari. (https://issues.chromium.org/issues/40615503, https://bugs.webkit.org/show_bug.cgi?id=195016)
*/
table {
border-color: currentcolor;
}
/*
Forms
=====
*/
/**
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
line-height: 1.15;
/* 1 */
margin: 0;
/* 2 */
}
/**
Correct the inability to style clickable types in iOS and Safari.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
}
/**
Remove the padding so developers are not caught out when they zero out 'fieldset' elements in all browsers.
*/
legend {
padding: 0;
}
/**
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/**
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/**
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/**
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/**
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to 'inherit' in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Interactive
===========
*/
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}

View File

@ -102,16 +102,15 @@ if config_env() == :prod do
# In production you need to configure the mailer to use a different adapter. # In production you need to configure the mailer to use a different adapter.
# Also, you may need to configure the Swoosh API client of your choice if you # Also, you may need to configure the Swoosh API client of your choice if you
# are not using SMTP. Here is an example of the configuration: # are not using SMTP. Here is an example of the configuration:
# config :send_it, SendIt.Mailer,
# config :send_it, SendIt.Mailer, adapter: Swoosh.Adapters.Mailgun,
# adapter: Swoosh.Adapters.Mailgun, api_key: System.get_env("MAILGUN_API_KEY"),
# api_key: System.get_env("MAILGUN_API_KEY"), domain: System.get_env("MAILGUN_DOMAIN")
# domain: System.get_env("MAILGUN_DOMAIN")
# #
# For this example you need include a HTTP client required by Swoosh API client. # For this example you need include a HTTP client required by Swoosh API client.
# Swoosh supports Hackney and Finch out of the box: # Swoosh supports Hackney and Finch out of the box:
# config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: SendIt.Finch
# config :swoosh, :api_client, Swoosh.ApiClient.Hackney
# #
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end end

View File

@ -21,6 +21,10 @@ defmodule SendIt.Marketing do
Repo.all(Contact) Repo.all(Contact)
end end
def list_subscribed_contacts do
Repo.all(from c in Contact, where: [subscribed: true])
end
@doc """ @doc """
Gets a single contact. Gets a single contact.
@ -37,6 +41,8 @@ defmodule SendIt.Marketing do
""" """
def get_contact!(id), do: Repo.get!(Contact, id) def get_contact!(id), do: Repo.get!(Contact, id)
def get_contact_by_email!(email), do: Repo.get_by!(Contact, email: email)
@doc """ @doc """
Creates a contact. Creates a contact.
@ -73,6 +79,18 @@ defmodule SendIt.Marketing do
|> Repo.update() |> Repo.update()
end end
def unsubscribe(email) do
case get_contact_by_email!(email) do
%Contact{} = contact ->
contact
|> Contact.changeset(%{subscribed: false})
|> Repo.update()
{:error, reason} ->
{:error, reason}
end
end
@doc """ @doc """
Deletes a contact. Deletes a contact.
@ -208,7 +226,7 @@ defmodule SendIt.Marketing do
""" """
def change_message(%Message{} = message, attrs \\ %{}) do def change_message(%Message{} = message, attrs \\ %{}) do
contacts = list_contacts() contacts = list_subscribed_contacts()
message message
|> Message.changeset(attrs) |> Message.changeset(attrs)

View File

@ -47,41 +47,32 @@ defmodule SendItWeb.CoreComponents do
phx-mounted={@show && show_modal(@id)} phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)} phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")} data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="hidden" class="modal-wrapper"
> >
<div id={"#{@id}-bg"} class="transition-opacity" aria-hidden="true" />
<div <div
class="fixed" class="modal"
aria-labelledby={"#{@id}-title"} aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"} aria-describedby={"#{@id}-description"}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
tabindex="0" tabindex="0"
> >
<div> <.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="modal-container"
>
<div> <div>
<.focus_wrap <button phx-click={JS.exec("data-cancel", to: "##{@id}")} type="button" aria-label="close">
id={"#{@id}-container"} &times;
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")} </button>
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="hidden transition"
>
<div>
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
aria-label="close"
>
<.icon name="hero-x-mark-solid" />
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div> </div>
</div> <div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div> </div>
</div> </div>
""" """
@ -197,12 +188,10 @@ defmodule SendItWeb.CoreComponents do
def simple_form(assigns) do def simple_form(assigns) do
~H""" ~H"""
<.form :let={f} for={@for} as={@as} {@rest}> <.form :let={f} for={@for} as={@as} {@rest} class="form-default">
<div> <%= render_slot(@inner_block, f) %>
<%= render_slot(@inner_block, f) %> <div :for={action <- @actions}>
<div :for={action <- @actions}> <%= render_slot(action, f) %>
<%= render_slot(action, f) %>
</div>
</div> </div>
</.form> </.form>
""" """
@ -305,8 +294,8 @@ defmodule SendItWeb.CoreComponents do
end) end)
~H""" ~H"""
<div> <div class="form-field">
<label> <label class="checkbox">
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} /> <input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<input type="checkbox" id={@id} name={@name} value="true" checked={@checked} {@rest} /> <input type="checkbox" id={@id} name={@name} value="true" checked={@checked} {@rest} />
<%= @label %> <%= @label %>
@ -318,7 +307,7 @@ defmodule SendItWeb.CoreComponents do
def input(%{type: "select"} = assigns) do def input(%{type: "select"} = assigns) do
~H""" ~H"""
<div> <div class="form-field">
<.label for={@id}><%= @label %></.label> <.label for={@id}><%= @label %></.label>
<select id={@id} name={@name} multiple={@multiple} {@rest}> <select id={@id} name={@name} multiple={@multiple} {@rest}>
<option :if={@prompt} value=""><%= @prompt %></option> <option :if={@prompt} value=""><%= @prompt %></option>
@ -331,7 +320,7 @@ defmodule SendItWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do def input(%{type: "textarea"} = assigns) do
~H""" ~H"""
<div> <div class="form-field">
<.label for={@id}><%= @label %></.label> <.label for={@id}><%= @label %></.label>
<textarea <textarea
id={@id} id={@id}
@ -351,7 +340,7 @@ defmodule SendItWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here... # All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do def input(assigns) do
~H""" ~H"""
<div> <div class="form-field">
<.label for={@id}><%= @label %></.label> <.label for={@id}><%= @label %></.label>
<input <input
type={@type} type={@type}
@ -391,7 +380,7 @@ defmodule SendItWeb.CoreComponents do
def error(assigns) do def error(assigns) do
~H""" ~H"""
<p> <p class="error">
<.icon name="hero-exclamation-circle-mini" /> <.icon name="hero-exclamation-circle-mini" />
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</p> </p>
@ -409,11 +398,11 @@ defmodule SendItWeb.CoreComponents do
def header(assigns) do def header(assigns) do
~H""" ~H"""
<header class={[@actions != [] && "", @class]}> <header class={[@actions != [] && "", "header-default", @class]}>
<div> <div>
<h1> <h2>
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</h1> </h2>
<p :if={@subtitle != []}> <p :if={@subtitle != []}>
<%= render_slot(@subtitle) %> <%= render_slot(@subtitle) %>
</p> </p>
@ -455,40 +444,38 @@ defmodule SendItWeb.CoreComponents do
end end
~H""" ~H"""
<div> <table class="table-default">
<table> <thead>
<thead> <tr>
<tr> <th :for={col <- @col}><%= col[:label] %></th>
<th :for={col <- @col}><%= col[:label] %></th> <th :if={@action != []}>
<th :if={@action != []}> <span>Actions</span>
<span>Actions</span> </th>
</th> </tr>
</tr> </thead>
</thead> <tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}>
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}> <tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}> <td
<td :for={{col, i} <- Enum.with_index(@col)}
:for={{col, i} <- Enum.with_index(@col)} phx-click={@row_click && @row_click.(row)}
phx-click={@row_click && @row_click.(row)} class={["", @row_click && ""]}
class={["", @row_click && ""]} >
> <div>
<div> <span class={["", i == 0 && ""]}>
<span class={["", i == 0 && ""]}> <%= render_slot(col, @row_item.(row)) %>
<%= render_slot(col, @row_item.(row)) %> </span>
</span> </div>
</div> </td>
</td> <td :if={@action != []}>
<td :if={@action != []}> <div class="table-actions">
<div> <span :for={action <- @action}>
<span :for={action <- @action}> <%= render_slot(action, @row_item.(row)) %>
<%= render_slot(action, @row_item.(row)) %> </span>
</span> </div>
</div> </td>
</td> </tr>
</tr> </tbody>
</tbody> </table>
</table>
</div>
""" """
end end

View File

@ -12,34 +12,44 @@
</script> </script>
</head> </head>
<body> <body>
<ul> <nav class="site-nav">
<%= if @current_user do %> <h1>Send It</h1>
<li> <ul class="nav-list">
<%= @current_user.email %> <%= if @current_user do %>
</li> <li>
<li> <.link href={~p"/messages"}>
<.link href={~p"/users/settings"}> Messages
Settings </.link>
</.link> </li>
</li> <li>
<li> <.link href={~p"/contacts"}>
<.link href={~p"/users/log_out"} method="delete"> Contacts
Log out </.link>
</.link> </li>
</li> <li>
<% else %> <.link href={~p"/users/settings"}>
<li> Settings
<.link href={~p"/users/register"}> </.link>
Register </li>
</.link> <li>
</li> <.link href={~p"/users/log_out"} method="delete">
<li> Log out
<.link href={~p"/users/log_in"}> </.link>
Log in </li>
</.link> <% else %>
</li> <li>
<% end %> <.link href={~p"/users/register"}>
</ul> Register
</.link>
</li>
<li>
<.link href={~p"/users/log_in"}>
Log in
</.link>
</li>
<% end %>
</ul>
</nav>
<%= @inner_content %> <%= @inner_content %>
</body> </body>
</html> </html>

View File

@ -1 +1,4 @@
<h1>Home</h1> <div class="banner">
<h2>Send It</h2>
<p>Instantly compose and send a newsletter to your contacts.</p>
</div>

View File

@ -0,0 +1,84 @@
defmodule SendItWeb.ContactLive.FormComponent do
use SendItWeb, :live_component
alias SendIt.Marketing
@impl true
def render(assigns) do
~H"""
<div>
<.header>
<%= @title %>
<:subtitle>Use this form to manage contact records in your database.</:subtitle>
</.header>
<.simple_form
for={@form}
id="contact-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:name]} type="text" label="Name" />
<.input field={@form[:email]} type="text" label="Email" />
<.input field={@form[:subscribed]} type="checkbox" label="Subscribed" />
<:actions>
<.button phx-disable-with="Saving...">Save Contact</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{contact: contact} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(Marketing.change_contact(contact))
end)}
end
@impl true
def handle_event("validate", %{"contact" => contact_params}, socket) do
changeset = Marketing.change_contact(socket.assigns.contact, contact_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"contact" => contact_params}, socket) do
save_contact(socket, socket.assigns.action, contact_params)
end
defp save_contact(socket, :edit, contact_params) do
case Marketing.update_contact(socket.assigns.contact, contact_params) do
{:ok, contact} ->
notify_parent({:saved, contact})
{:noreply,
socket
|> put_flash(:info, "Contact updated successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_contact(socket, :new, contact_params) do
case Marketing.create_contact(contact_params) do
{:ok, contact} ->
notify_parent({:saved, contact})
{:noreply,
socket
|> put_flash(:info, "Contact created successfully")
|> push_patch(to: socket.assigns.patch)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

View File

@ -0,0 +1,41 @@
defmodule SendItWeb.ContactLive.Index do
use SendItWeb, :live_view
alias SendIt.Marketing
alias SendIt.Marketing.Contact
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :contacts, Marketing.list_contacts())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Contact")
|> assign(:contact, %Contact{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Contacts")
|> assign(:contact, nil)
end
@impl true
def handle_info({SendItWeb.ContactLive.FormComponent, {:saved, contact}}, socket) do
{:noreply, stream_insert(socket, :contacts, contact)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
contact = Marketing.get_contact!(id)
{:ok, _} = Marketing.delete_contact(contact)
{:noreply, stream_delete(socket, :contacts, contact)}
end
end

View File

@ -0,0 +1,37 @@
<.header>
Contacts
<:actions>
<.link patch={~p"/contacts/new"}>
<.button>New Contact</.button>
</.link>
</:actions>
</.header>
<.table
id="contacts"
rows={@streams.contacts}
row_click={fn {_id, contact} -> JS.navigate(~p"/contacts/#{contact}") end}
>
<:col :let={{_id, contact}} label="Name"><%= contact.name %></:col>
<:col :let={{_id, contact}} label="Email"><%= contact.email %></:col>
<:col :let={{_id, contact}} label="Subscribed"><%= contact.subscribed %></:col>
<:action :let={{id, contact}}>
<.link
phx-click={JS.push("delete", value: %{id: contact.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal :if={@live_action in [:new]} id="contact-modal" show on_cancel={JS.patch(~p"/contacts")}>
<.live_component
module={SendItWeb.ContactLive.FormComponent}
id={:new}
title={@page_title}
action={@live_action}
contact={@contact}
patch={~p"/contacts"}
/>
</.modal>

View File

@ -0,0 +1,21 @@
defmodule SendItWeb.ContactLive.Show do
use SendItWeb, :live_view
alias SendIt.Marketing
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:contact, Marketing.get_contact!(id))}
end
defp page_title(:show), do: "Show Contact"
defp page_title(:edit), do: "Edit Contact"
end

View File

@ -0,0 +1,28 @@
<.header>
Contact <%= @contact.id %>
<:subtitle>This is a contact record from your database.</:subtitle>
<:actions>
<.link patch={~p"/contacts/#{@contact}/edit"} phx-click={JS.push_focus()}>
<.button>Edit contact</.button>
</.link>
</:actions>
</.header>
<.list>
<:item title="Name"><%= @contact.name %></:item>
<:item title="Email"><%= @contact.email %></:item>
<:item title="Subscribed"><%= @contact.subscribed %></:item>
</.list>
<.back navigate={~p"/contacts"}>Back to contacts</.back>
<.modal :if={@live_action == :edit} id="contact-modal" show on_cancel={JS.patch(~p"/contacts/#{@contact}")}>
<.live_component
module={SendItWeb.ContactLive.FormComponent}
id={@contact.id}
title={@page_title}
action={@live_action}
contact={@contact}
patch={~p"/contacts/#{@contact}"}
/>
</.modal>

View File

@ -6,10 +6,10 @@ defmodule SendItWeb.MessageLive.FormComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div> <div class="flex-column">
<.header> <.header>
<%= @title %> <%= @title %>
<:subtitle>Use this form to manage message records in your database.</:subtitle> <:subtitle>Messages will automatically be sent to all contacts.</:subtitle>
</.header> </.header>
<.simple_form <.simple_form
@ -22,7 +22,9 @@ defmodule SendItWeb.MessageLive.FormComponent do
<.input field={@form[:subject]} type="text" label="Subject" /> <.input field={@form[:subject]} type="text" label="Subject" />
<.input field={@form[:content]} type="textarea" label="Content" /> <.input field={@form[:content]} type="textarea" label="Content" />
<:actions> <:actions>
<.button phx-disable-with="Saving...">Save Message</.button> <.button data-confirm="This will send to all contacts!" phx-disable-with="Sending...">
Send Message
</.button>
</:actions> </:actions>
</.simple_form> </.simple_form>
</div> </div>

View File

@ -1,5 +1,5 @@
<.header> <.header>
Listing Messages Messages
<:actions> <:actions>
<.link patch={~p"/messages/new"}> <.link patch={~p"/messages/new"}>
<.button>New Message</.button> <.button>New Message</.button>
@ -15,14 +15,9 @@
<:col :let={{_id, message}} label="Subject"> <:col :let={{_id, message}} label="Subject">
<%= message.subject %> <%= message.subject %>
</:col> </:col>
<:col :let={{_id, message}} label="Content"> <:col :let={{_id, message}} label="Created">
<%= message.content %> <%= message.inserted_at %>
</:col> </:col>
<:action :let={{_id, message}}>
<div class="sr-only">
<.link navigate={~p"/messages/#{message}"}>Show</.link>
</div>
</:action>
<:action :let={{id, message}}> <:action :let={{id, message}}>
<.link <.link
phx-click={JS.push("delete", value: %{id: message.id}) |> hide("##{id}")} phx-click={JS.push("delete", value: %{id: message.id}) |> hide("##{id}")}

View File

@ -0,0 +1,48 @@
defmodule SendItWeb.SubscriptionLive do
use SendItWeb, :live_view
alias SendIt.Marketing
@impl true
def mount(_params, _session, socket) do
{:ok, assign_form(socket)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.header>
Unsubscribe
<:subtitle>Enter your email address below to unsubscribe from all emails.</:subtitle>
</.header>
<.simple_form for={@form} id="subscription-form" phx-submit="save">
<.input type="email" field={@form[:email]} />
<:actions>
<.button phx-disable-with="Saving changes...">Unsubscribe</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def handle_event("save", %{"email" => email}, socket) do
unsubscribe(socket, email)
end
defp unsubscribe(socket, email) do
case Marketing.unsubscribe(email) do
{:ok, _contact} ->
{:noreply, put_flash(socket, :info, "You have been unsubscribed from all emails.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
defp assign_form(socket) do
assign(socket, :form, to_form(%{"email" => ""}))
end
end

View File

@ -21,6 +21,7 @@ defmodule SendItWeb.Router do
pipe_through :browser pipe_through :browser
get "/", PageController, :home get "/", PageController, :home
live "/subscription", SubscriptionLive
end end
# Other scopes may use custom stacks. # Other scopes may use custom stacks.
@ -70,6 +71,11 @@ defmodule SendItWeb.Router do
live "/messages/new", MessageLive.Index, :new live "/messages/new", MessageLive.Index, :new
live "/messages/:id", MessageLive.Show, :show live "/messages/:id", MessageLive.Show, :show
live "/contacts", ContactLive.Index, :index
live "/contacts/new", ContactLive.Index, :new
live "/contacts/:id", ContactLive.Show, :show
live "/contacts/:id/edit", ContactLive.Show, :edit
live "/users/settings", UserSettingsLive, :edit live "/users/settings", UserSettingsLive, :edit
live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email live "/users/settings/confirm_email/:token", UserSettingsLive, :confirm_email
end end

View File

@ -50,7 +50,8 @@ defmodule SendIt.MixProject do
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"}, {:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"} {:bandit, "~> 1.5"},
{:multipart, "~> 0.4.0"}
] ]
end end

View File

@ -18,6 +18,7 @@
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"multipart": {:hex, :multipart, "0.4.0", "634880a2148d4555d050963373d0e3bbb44a55b2badd87fa8623166172e9cda0", [:mix], [{:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm", "3c5604bc2fb17b3137e5d2abdf5dacc2647e60c5cc6634b102cf1aef75a06f0a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"},

View File

@ -0,0 +1,113 @@
defmodule SendItWeb.ContactLiveTest do
use SendItWeb.ConnCase
import Phoenix.LiveViewTest
import SendIt.MarketingFixtures
@create_attrs %{name: "some name", email: "some email", subscribed: true}
@update_attrs %{name: "some updated name", email: "some updated email", subscribed: false}
@invalid_attrs %{name: nil, email: nil, subscribed: false}
defp create_contact(_) do
contact = contact_fixture()
%{contact: contact}
end
describe "Index" do
setup [:create_contact]
test "lists all contacts", %{conn: conn, contact: contact} do
{:ok, _index_live, html} = live(conn, ~p"/contacts")
assert html =~ "Listing Contacts"
assert html =~ contact.name
end
test "saves new contact", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/contacts")
assert index_live |> element("a", "New Contact") |> render_click() =~
"New Contact"
assert_patch(index_live, ~p"/contacts/new")
assert index_live
|> form("#contact-form", contact: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert index_live
|> form("#contact-form", contact: @create_attrs)
|> render_submit()
assert_patch(index_live, ~p"/contacts")
html = render(index_live)
assert html =~ "Contact created successfully"
assert html =~ "some name"
end
test "updates contact in listing", %{conn: conn, contact: contact} do
{:ok, index_live, _html} = live(conn, ~p"/contacts")
assert index_live |> element("#contacts-#{contact.id} a", "Edit") |> render_click() =~
"Edit Contact"
assert_patch(index_live, ~p"/contacts/#{contact}/edit")
assert index_live
|> form("#contact-form", contact: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert index_live
|> form("#contact-form", contact: @update_attrs)
|> render_submit()
assert_patch(index_live, ~p"/contacts")
html = render(index_live)
assert html =~ "Contact updated successfully"
assert html =~ "some updated name"
end
test "deletes contact in listing", %{conn: conn, contact: contact} do
{:ok, index_live, _html} = live(conn, ~p"/contacts")
assert index_live |> element("#contacts-#{contact.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#contacts-#{contact.id}")
end
end
describe "Show" do
setup [:create_contact]
test "displays contact", %{conn: conn, contact: contact} do
{:ok, _show_live, html} = live(conn, ~p"/contacts/#{contact}")
assert html =~ "Show Contact"
assert html =~ contact.name
end
test "updates contact within modal", %{conn: conn, contact: contact} do
{:ok, show_live, _html} = live(conn, ~p"/contacts/#{contact}")
assert show_live |> element("a", "Edit") |> render_click() =~
"Edit Contact"
assert_patch(show_live, ~p"/contacts/#{contact}/show/edit")
assert show_live
|> form("#contact-form", contact: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert show_live
|> form("#contact-form", contact: @update_attrs)
|> render_submit()
assert_patch(show_live, ~p"/contacts/#{contact}")
html = render(show_live)
assert html =~ "Contact updated successfully"
assert html =~ "some updated name"
end
end
end