Compare commits

..

No commits in common. "e2530762859924a4ef21b8301ca197a0287bd018" and "4e6dcd0c7b5e37ee42cfbf61852abbd172546046" have entirely different histories.

48 changed files with 470 additions and 2188 deletions

View File

@ -1,45 +0,0 @@
# This file excludes paths from the Docker build context.
#
# By default, Docker's build context includes all files (and folders) in the
# current directory. Even if a file isn't copied into the container it is still sent to
# the Docker daemon.
#
# There are multiple reasons to exclude files from the build context:
#
# 1. Prevent nested folders from being copied into the container (ex: exclude
# /assets/node_modules when copying /assets)
# 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
# 3. Avoid sending files containing sensitive information
#
# More information on using .dockerignore is available here:
# https://docs.docker.com/engine/reference/builder/#dockerignore-file
.dockerignore
# Ignore git, but keep git HEAD and refs to access current commit hash if needed:
#
# $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
# d0b8727759e1e0e7aa3d41707d12376e373d5ecc
.git
!.git/HEAD
!.git/refs
# Common development/test artifacts
/cover/
/doc/
/test/
/tmp/
.elixir_ls
# Mix artifacts
/_build/
/deps/
*.ez
# Generated on crash by the VM
erl_crash.dump
# Static artifacts - These should be fetched and built inside the Docker image
/assets/node_modules/
/priv/static/assets/
/priv/static/cache_manifest.json

2
.gitignore vendored
View File

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

View File

@ -1,97 +0,0 @@
# Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
# instead of Alpine to avoid DNS resolution issues in production.
#
# https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
# https://hub.docker.com/_/ubuntu?tab=tags
#
# This file is based on these images:
#
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20241111-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.17.3-erlang-27.1.2-debian-bullseye-20241111-slim
#
ARG ELIXIR_VERSION=1.17.3
ARG OTP_VERSION=27.1.2
ARG DEBIAN_VERSION=bullseye-20241111-slim
ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
FROM ${BUILDER_IMAGE} as builder
# install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir
WORKDIR /app
# install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
# set build ENV
ENV MIX_ENV="prod"
# install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# copy compile-time config files before we compile dependencies
# to ensure any relevant config change will trigger the dependencies
# to be re-compiled.
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# compile assets
RUN mix assets.deploy
# Compile the release
RUN mix compile
# Changes to config/runtime.exs don't require recompiling the code
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# start a new build stage so that the final image will only contain
# the compiled release and other runtime necessities
FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# set runner ENV
ENV MIX_ENV="prod"
# Only copy the final release from the build stage
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/send_it ./
USER nobody
# If using an environment that doesn't automatically reap zombie processes, it is
# advised to add an init process such as tini via `apt-get install`
# above and adding an entrypoint. See https://github.com/krallin/tini for details
# ENTRYPOINT ["/tini", "--"]
CMD ["/app/bin/server"]

View File

@ -1,349 +0,0 @@
@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;
}
}
.dl-default {
display: flex;
flex-direction: column;
gap: 1rem;
& dt {
font-weight: bold;
}
& dd {
padding: 1rem;
background-color: var(--grey-dark);
margin: 0;
border-radius: 1rem;
}
}
.error {
color: var(--red);
}
.back-link {
margin-bottom: 2rem;
}
.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;
}
}
.text-content {
& h1,
h2,
h3,
h4,
h5,
h6,
p:not(:last-child) {
margin-bottom: 1rem;
}
}

View File

@ -1,227 +0,0 @@
/*! 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

@ -36,7 +36,7 @@ config :esbuild,
version: "0.17.11",
send_it: [
args:
~w(js/app.js css/app.css --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]

View File

@ -102,15 +102,16 @@ if config_env() == :prod do
# 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
# are not using SMTP. Here is an example of the configuration:
config :send_it, SendIt.Mailer,
adapter: Swoosh.Adapters.Mailgun,
api_key: System.get_env("MAILGUN_API_KEY"),
domain: System.get_env("MAILGUN_DOMAIN")
#
# config :send_it, SendIt.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# For this example you need include a HTTP client required by Swoosh API client.
# 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.
end

View File

@ -1,35 +0,0 @@
# fly.toml app configuration file generated for send-it on 2024-11-19T10:21:25-07:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'send-it'
primary_region = 'den'
kill_signal = 'SIGTERM'
[build]
[deploy]
release_command = '/app/bin/migrate'
[env]
PHX_HOST = 'send-it.fly.dev'
PORT = '8080'
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[http_service.concurrency]
type = 'connections'
hard_limit = 1000
soft_limit = 1000
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

View File

@ -1,239 +0,0 @@
defmodule SendIt.Marketing do
@moduledoc """
The Marketing context.
"""
import Ecto.Query, warn: false
alias SendIt.Repo
alias SendIt.Marketing.{Contact, Message, MessageNotifier}
@doc """
Returns the list of contacts.
## Examples
iex> list_contacts()
[%Contact{}, ...]
"""
def list_contacts(opts \\ [offset: 0, limit: 20]) do
Repo.all(
from c in Contact,
offset: ^opts[:offset],
limit: ^opts[:limit]
)
end
def list_subscribed_contacts do
Repo.all(from c in Contact, where: [subscribed: true])
end
@doc """
Gets a single contact.
Raises `Ecto.NoResultsError` if the Contact does not exist.
## Examples
iex> get_contact!(123)
%Contact{}
iex> get_contact!(456)
** (Ecto.NoResultsError)
"""
def get_contact!(id), do: Repo.get!(Contact, id)
def get_contact_by_email!(email), do: Repo.get_by!(Contact, email: email)
@doc """
Creates a contact.
## Examples
iex> create_contact(%{field: value})
{:ok, %Contact{}}
iex> create_contact(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_contact(attrs \\ %{}) do
%Contact{}
|> Contact.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a contact.
## Examples
iex> update_contact(contact, %{field: new_value})
{:ok, %Contact{}}
iex> update_contact(contact, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_contact(%Contact{} = contact, attrs) do
contact
|> Contact.changeset(attrs)
|> Repo.update()
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 """
Deletes a contact.
## Examples
iex> delete_contact(contact)
{:ok, %Contact{}}
iex> delete_contact(contact)
{:error, %Ecto.Changeset{}}
"""
def delete_contact(%Contact{} = contact) do
Repo.delete(contact)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking contact changes.
## Examples
iex> change_contact(contact)
%Ecto.Changeset{data: %Contact{}}
"""
def change_contact(%Contact{} = contact, attrs \\ %{}) do
Contact.changeset(contact, attrs)
end
@doc """
Returns the list of messages.
## Examples
iex> list_messages()
[%Message{}, ...]
"""
def list_messages do
Repo.all(from m in Message, order_by: [desc: m.inserted_at])
end
@doc """
Gets a single message.
Raises `Ecto.NoResultsError` if the Message does not exist.
## Examples
iex> get_message!(123)
%Message{}
iex> get_message!(456)
** (Ecto.NoResultsError)
"""
def get_message!(id), do: Repo.get!(Message, id)
@doc """
Creates a message.
## Examples
iex> create_message(%{field: value})
{:ok, %Message{}}
iex> create_message(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_message(attrs \\ %{}) do
%Message{}
|> change_message(attrs)
|> Repo.insert()
end
@doc """
Delivers the given message to the message's contacts list
## Examples
iex> deliver_message(message)
{:ok, %{to: ..., body: ...}}
"""
def deliver_message(%Message{} = message) do
message
|> Repo.preload(:contacts)
|> MessageNotifier.deliver_message()
end
@doc """
Updates a message.
## Examples
iex> update_message(message, %{field: new_value})
{:ok, %Message{}}
iex> update_message(message, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_message(%Message{} = message, attrs) do
message
|> change_message(attrs)
|> Repo.update()
end
@doc """
Deletes a message.
## Examples
iex> delete_message(message)
{:ok, %Message{}}
iex> delete_message(message)
{:error, %Ecto.Changeset{}}
"""
def delete_message(%Message{} = message) do
Repo.delete(message)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking message changes.
## Examples
iex> change_message(message)
%Ecto.Changeset{data: %Message{}}
"""
def change_message(%Message{} = message, attrs \\ %{}) do
contacts = list_subscribed_contacts()
message
|> Message.changeset(attrs)
|> Ecto.Changeset.put_assoc(:contacts, contacts)
end
end

View File

@ -1,22 +0,0 @@
defmodule SendIt.Marketing.Contact do
use Ecto.Schema
import Ecto.Changeset
schema "contacts" do
field :name, :string
field :email, :string
field :subscribed, :boolean, default: true
many_to_many :messages, SendIt.Marketing.Message, join_through: "contacts_messages"
timestamps(type: :utc_datetime)
end
@doc false
def changeset(contact, attrs) do
contact
|> cast(attrs, [:name, :email, :subscribed])
|> validate_required([:name, :email, :subscribed])
|> unique_constraint(:email)
end
end

View File

@ -1,20 +0,0 @@
defmodule SendIt.Marketing.Message do
use Ecto.Schema
import Ecto.Changeset
schema "messages" do
field :subject, :string
field :content, :string
many_to_many :contacts, SendIt.Marketing.Contact, join_through: "contacts_messages"
timestamps(type: :utc_datetime)
end
@doc false
def changeset(message, attrs) do
message
|> cast(attrs, [:subject, :content])
|> validate_required([:subject, :content])
end
end

View File

@ -1,74 +0,0 @@
defmodule SendIt.Marketing.MessageNotifier do
use SendItWeb, :html
require Logger
import Swoosh.Email
alias SendIt.Mailer
@from_name "Port Townsend Roasting Co."
@from_email "newsletter@ptcoffee.com"
@chunk_by 900
def deliver_message(message) do
contacts = message.contacts |> format_recipients() |> Enum.chunk_every(@chunk_by)
body = message.content |> format_body()
Enum.map(contacts, &process_batch(&1, message.subject, body))
end
defp process_batch(batch, subject, body) do
Task.async(fn -> deliver(batch, subject, body) end) |> Task.await()
end
defp deliver(recipients, subject, body) do
email =
new(
to: recipients,
from: {@from_name, @from_email},
subject: subject,
html_body: body
)
|> put_provider_option(:recipient_vars, format_recipient_vars(recipients))
case Mailer.deliver(email) do
{:ok, _} ->
{:ok, email}
{:error, reason} ->
Logger.warning("Sending email failed: #{inspect(reason)}")
{:error, reason}
end
end
defp format_recipients(recipients) do
Enum.map(recipients, &{&1.name, &1.email})
end
defp format_recipient_vars(recipients) do
Enum.reduce(recipients, %{}, fn {name, email}, acc ->
Map.put_new(acc, email, %{name: name, email: email})
end)
end
defp format_body(content) do
content
|> add_unsubscription()
end
defp add_unsubscription(content) do
url = url(~p"/subscription")
"""
#{content}
<br />
<br />
<hr />
<p>
<strong>Unsubscribe</strong><br />
If you no longer wish to receive our emails, you can <a href="#{url}?email=%recipient.email%">unsubscribe here</a>.
</p>
"""
end
end

View File

@ -1,28 +0,0 @@
defmodule SendIt.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :send_it
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View File

@ -47,32 +47,42 @@ defmodule SendItWeb.CoreComponents do
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="modal-wrapper"
class="relative z-50 hidden"
>
<div id={"#{@id}-bg"} class="bg-zinc-50/90 fixed inset-0 transition-opacity" aria-hidden="true" />
<div
class="modal"
class="fixed inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<.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>
<button phx-click={JS.exec("data-cancel", to: "##{@id}")} type="button" aria-label="close">
&times;
</button>
<div class="flex min-h-full items-center justify-center">
<div class="w-full max-w-3xl p-4 sm:p-6 lg:py-8">
<.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="shadow-zinc-700/10 ring-zinc-700/10 relative hidden rounded-2xl bg-white p-14 shadow-lg ring-1 transition"
>
<div class="absolute top-6 right-5">
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-20 hover:opacity-40"
aria-label="close"
>
<.icon name="hero-x-mark-solid" class="h-5 w-5" />
</button>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
<div id={"#{@id}-content"}>
<%= render_slot(@inner_block) %>
</div>
</.focus_wrap>
</div>
</div>
</div>
"""
@ -104,20 +114,20 @@ defmodule SendItWeb.CoreComponents do
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class={[
"fixed",
@kind == :info && "",
@kind == :error && ""
"fixed top-2 right-2 mr-2 w-80 sm:w-96 z-50 rounded-lg p-3 ring-1",
@kind == :info && "bg-emerald-50 text-emerald-800 ring-emerald-500 fill-cyan-900",
@kind == :error && "bg-rose-50 text-rose-900 shadow-md ring-rose-500 fill-rose-900"
]}
{@rest}
>
<p :if={@title}>
<.icon :if={@kind == :info} name="hero-information-circle-mini" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" />
<p :if={@title} class="flex items-center gap-1.5 text-sm font-semibold leading-6">
<.icon :if={@kind == :info} name="hero-information-circle-mini" class="h-4 w-4" />
<.icon :if={@kind == :error} name="hero-exclamation-circle-mini" class="h-4 w-4" />
<%= @title %>
</p>
<p><%= msg %></p>
<button type="button" aria-label="close">
<.icon name="hero-x-mark-solid" />
<p class="mt-2 text-sm leading-5"><%= msg %></p>
<button type="button" class="group absolute top-1 right-1 p-2" aria-label="close">
<.icon name="hero-x-mark-solid" class="h-5 w-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
"""
@ -146,7 +156,8 @@ defmodule SendItWeb.CoreComponents do
phx-connected={hide("#client-error")}
hidden
>
Attempting to reconnect <.icon name="hero-arrow-path" />
Attempting to reconnect
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
<.flash
@ -157,7 +168,8 @@ defmodule SendItWeb.CoreComponents do
phx-connected={hide("#server-error")}
hidden
>
Hang in there while we get back on track <.icon name="hero-arrow-path" />
Hang in there while we get back on track
<.icon name="hero-arrow-path" class="ml-1 h-3 w-3 animate-spin" />
</.flash>
</div>
"""
@ -188,10 +200,12 @@ defmodule SendItWeb.CoreComponents do
def simple_form(assigns) do
~H"""
<.form :let={f} for={@for} as={@as} {@rest} class="form-default">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions}>
<%= render_slot(action, f) %>
<.form :let={f} for={@for} as={@as} {@rest}>
<div class="mt-10 space-y-8 bg-white">
<%= render_slot(@inner_block, f) %>
<div :for={action <- @actions} class="mt-2 flex items-center justify-between gap-6">
<%= render_slot(action, f) %>
</div>
</div>
</.form>
"""
@ -203,7 +217,7 @@ defmodule SendItWeb.CoreComponents do
## Examples
<.button>Send!</.button>
<.button phx-click="go">Send!</.button>
<.button phx-click="go" class="ml-2">Send!</.button>
"""
attr :type, :string, default: nil
attr :class, :string, default: nil
@ -216,8 +230,8 @@ defmodule SendItWeb.CoreComponents do
<button
type={@type}
class={[
"phx-submit-loading:opacity-75",
"",
"phx-submit-loading:opacity-75 rounded-lg bg-zinc-900 hover:bg-zinc-700 py-2 px-3",
"text-sm font-semibold leading-6 text-white active:text-white/80",
@class
]}
{@rest}
@ -294,10 +308,18 @@ defmodule SendItWeb.CoreComponents do
end)
~H"""
<div class="form-field">
<label class="checkbox">
<div>
<label class="flex items-center gap-4 text-sm leading-6 text-zinc-600">
<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}
class="rounded border-zinc-300 text-zinc-900 focus:ring-0"
{@rest}
/>
<%= @label %>
</label>
<.error :for={msg <- @errors}><%= msg %></.error>
@ -307,9 +329,15 @@ defmodule SendItWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<div class="form-field">
<div>
<.label for={@id}><%= @label %></.label>
<select id={@id} name={@name} multiple={@multiple} {@rest}>
<select
id={@id}
name={@name}
class="mt-2 block w-full rounded-md border border-gray-300 bg-white shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm"
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value=""><%= @prompt %></option>
<%= Phoenix.HTML.Form.options_for_select(@options, @value) %>
</select>
@ -320,15 +348,15 @@ defmodule SendItWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="form-field">
<div>
<.label for={@id}><%= @label %></.label>
<textarea
id={@id}
name={@name}
class={[
"",
@errors == [] && "",
@errors != [] && ""
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6 min-h-[6rem]",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
><%= Phoenix.HTML.Form.normalize_value("textarea", @value) %></textarea>
@ -340,7 +368,7 @@ defmodule SendItWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="form-field">
<div>
<.label for={@id}><%= @label %></.label>
<input
type={@type}
@ -348,9 +376,9 @@ defmodule SendItWeb.CoreComponents do
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
"",
@errors == [] && "",
@errors != [] && ""
"mt-2 block w-full rounded-lg text-zinc-900 focus:ring-0 sm:text-sm sm:leading-6",
@errors == [] && "border-zinc-300 focus:border-zinc-400",
@errors != [] && "border-rose-400 focus:border-rose-400"
]}
{@rest}
/>
@ -367,7 +395,7 @@ defmodule SendItWeb.CoreComponents do
def label(assigns) do
~H"""
<label for={@for}>
<label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
<%= render_slot(@inner_block) %>
</label>
"""
@ -380,8 +408,8 @@ defmodule SendItWeb.CoreComponents do
def error(assigns) do
~H"""
<p class="error">
<.icon name="hero-exclamation-circle-mini" />
<p class="mt-3 flex gap-3 text-sm leading-6 text-rose-600">
<.icon name="hero-exclamation-circle-mini" class="mt-0.5 h-5 w-5 flex-none" />
<%= render_slot(@inner_block) %>
</p>
"""
@ -398,16 +426,16 @@ defmodule SendItWeb.CoreComponents do
def header(assigns) do
~H"""
<header class={[@actions != [] && "", "header-default", @class]}>
<header class={[@actions != [] && "flex items-center justify-between gap-6", @class]}>
<div>
<h2>
<h1 class="text-lg font-semibold leading-8 text-zinc-800">
<%= render_slot(@inner_block) %>
</h2>
<p :if={@subtitle != []}>
</h1>
<p :if={@subtitle != []} class="mt-2 text-sm leading-6 text-zinc-600">
<%= render_slot(@subtitle) %>
</p>
</div>
<div><%= render_slot(@actions) %></div>
<div class="flex-none"><%= render_slot(@actions) %></div>
</header>
"""
end
@ -436,7 +464,6 @@ defmodule SendItWeb.CoreComponents do
end
slot :action, doc: "the slot for showing user actions in the last table column"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to a table"
def table(assigns) do
assigns =
@ -445,38 +472,49 @@ defmodule SendItWeb.CoreComponents do
end
~H"""
<table class="table-default">
<thead>
<tr>
<th :for={col <- @col}><%= col[:label] %></th>
<th :if={@action != []}>
<span>Actions</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} {@rest}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["", @row_click && ""]}
>
<div>
<span class={["", i == 0 && ""]}>
<%= render_slot(col, @row_item.(row)) %>
</span>
</div>
</td>
<td :if={@action != []}>
<div class="table-actions">
<span :for={action <- @action}>
<%= render_slot(action, @row_item.(row)) %>
</span>
</div>
</td>
</tr>
</tbody>
</table>
<div class="overflow-y-auto px-4 sm:overflow-visible sm:px-0">
<table class="w-[40rem] mt-11 sm:w-full">
<thead class="text-sm text-left leading-6 text-zinc-500">
<tr>
<th :for={col <- @col} class="p-0 pb-4 pr-6 font-normal"><%= col[:label] %></th>
<th :if={@action != []} class="relative p-0 pb-4">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody
id={@id}
phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}
class="relative divide-y divide-zinc-100 border-t border-zinc-200 text-sm leading-6 text-zinc-700"
>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)} class="group hover:bg-zinc-50">
<td
:for={{col, i} <- Enum.with_index(@col)}
phx-click={@row_click && @row_click.(row)}
class={["relative p-0", @row_click && "hover:cursor-pointer"]}
>
<div class="block py-4 pr-6">
<span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 sm:rounded-l-xl" />
<span class={["relative", i == 0 && "font-semibold text-zinc-900"]}>
<%= render_slot(col, @row_item.(row)) %>
</span>
</div>
</td>
<td :if={@action != []} class="relative w-14 p-0">
<div class="relative whitespace-nowrap py-4 text-right text-sm font-medium">
<span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 sm:rounded-r-xl" />
<span
:for={action <- @action}
class="relative ml-4 font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<%= render_slot(action, @row_item.(row)) %>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
"""
end
@ -496,12 +534,14 @@ defmodule SendItWeb.CoreComponents do
def list(assigns) do
~H"""
<dl class="dl-default">
<div :for={item <- @item}>
<dt><%= item.title %></dt>
<dd><%= render_slot(item) %></dd>
</div>
</dl>
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={item <- @item} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500"><%= item.title %></dt>
<dd class="text-zinc-700"><%= render_slot(item) %></dd>
</div>
</dl>
</div>
"""
end
@ -517,9 +557,13 @@ defmodule SendItWeb.CoreComponents do
def back(assigns) do
~H"""
<div class="back-link">
<.link navigate={@navigate}>
&larr; <%= render_slot(@inner_block) %>
<div class="mt-16">
<.link
navigate={@navigate}
class="text-sm font-semibold leading-6 text-zinc-900 hover:text-zinc-700"
>
<.icon name="hero-arrow-left-solid" class="h-3 w-3" />
<%= render_slot(@inner_block) %>
</.link>
</div>
"""
@ -541,7 +585,7 @@ defmodule SendItWeb.CoreComponents do
## Examples
<.icon name="hero-x-mark-solid" />
<.icon name="hero-arrow-path" />
<.icon name="hero-arrow-path" class="ml-1 w-3 h-3 animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: nil
@ -625,11 +669,4 @@ defmodule SendItWeb.CoreComponents do
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
@doc """
Converts time into readable time
"""
def format_date(date) do
Calendar.strftime(date, "%a, %b %d %Y")
end
end

View File

@ -1,4 +1,32 @@
<main>
<.flash_group flash={@flash} />
<%= @inner_content %>
<header class="px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm">
<div class="flex items-center gap-4">
<a href="/">
<img src={~p"/images/logo.svg"} width="36" />
</a>
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %>
</p>
</div>
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900">
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700">
@elixirphoenix
</a>
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
GitHub
</a>
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80"
>
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</div>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl">
<.flash_group flash={@flash} />
<%= @inner_content %>
</div>
</main>

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" class="[scrollbar-gutter:stable]">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -7,44 +7,52 @@
<.live_title suffix=" · Phoenix Framework">
<%= assigns[:page_title] || "SendIt" %>
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
<link phx-track-static rel="stylesheet" href={~p"/assets/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script>
</head>
<body>
<nav class="site-nav">
<h1>Send It</h1>
<ul class="nav-list">
<%= if @current_user do %>
<li>
<.link href={~p"/messages"}>
Messages
</.link>
</li>
<li>
<.link href={~p"/contacts"}>
Contacts
</.link>
</li>
<li>
<.link href={~p"/users/settings"}>
Settings
</.link>
</li>
<li>
<.link href={~p"/users/log_out"} method="delete">
Log out
</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/log_in"}>
Log in
</.link>
</li>
<% end %>
</ul>
</nav>
<body class="bg-white">
<ul class="relative z-10 flex items-center gap-4 px-4 sm:px-6 lg:px-8 justify-end">
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
<%= @current_user.email %>
</li>
<li>
<.link
href={~p"/users/settings"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Settings
</.link>
</li>
<li>
<.link
href={~p"/users/log_out"}
method="delete"
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log out
</.link>
</li>
<% else %>
<li>
<.link
href={~p"/users/register"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Register
</.link>
</li>
<li>
<.link
href={~p"/users/log_in"}
class="text-[0.8125rem] leading-6 text-zinc-900 font-semibold hover:text-zinc-700"
>
Log in
</.link>
</li>
<% end %>
</ul>
<%= @inner_content %>
</body>
</html>

View File

@ -2,6 +2,8 @@ defmodule SendItWeb.PageController do
use SendItWeb, :controller
def home(conn, _params) do
render(conn, :home)
# The home page is often custom made,
# so skip the default app layout.
render(conn, :home, layout: false)
end
end

View File

@ -1,4 +1,223 @@
<div class="banner">
<h2>Send It</h2>
<p>Instantly compose and send a newsletter to your contacts.</p>
<link phx-track-static rel="stylesheet" href={~p"/assets/home.css"} />
<.flash_group flash={@flash} />
<div class="left-[40rem] fixed inset-y-0 right-0 z-0 hidden lg:block xl:left-[50rem]">
<svg
viewBox="0 0 1480 957"
fill="none"
aria-hidden="true"
class="absolute inset-0 h-full w-full"
preserveAspectRatio="xMinYMid slice"
>
<path fill="#EE7868" d="M0 0h1480v957H0z" />
<path
d="M137.542 466.27c-582.851-48.41-988.806-82.127-1608.412 658.2l67.39 810 3083.15-256.51L1535.94-49.622l-98.36 8.183C1269.29 281.468 734.115 515.799 146.47 467.012l-8.928-.742Z"
fill="#FF9F92"
/>
<path
d="M371.028 528.664C-169.369 304.988-545.754 149.198-1361.45 665.565l-182.58 792.025 3014.73 694.98 389.42-1689.25-96.18-22.171C1505.28 697.438 924.153 757.586 379.305 532.09l-8.277-3.426Z"
fill="#FA8372"
/>
<path
d="M359.326 571.714C-104.765 215.795-428.003-32.102-1349.55 255.554l-282.3 1224.596 3047.04 722.01 312.24-1354.467C1411.25 1028.3 834.355 935.995 366.435 577.166l-7.109-5.452Z"
fill="#E96856"
fill-opacity=".6"
/>
<path
d="M1593.87 1236.88c-352.15 92.63-885.498-145.85-1244.602-613.557l-5.455-7.105C-12.347 152.31-260.41-170.8-1225-131.458l-368.63 1599.048 3057.19 704.76 130.31-935.47Z"
fill="#C42652"
fill-opacity=".2"
/>
<path
d="M1411.91 1526.93c-363.79 15.71-834.312-330.6-1085.883-863.909l-3.822-8.102C72.704 125.95-101.074-242.476-1052.01-408.907l-699.85 1484.267 2837.75 1338.01 326.02-886.44Z"
fill="#A41C42"
fill-opacity=".2"
/>
<path
d="M1116.26 1863.69c-355.457-78.98-720.318-535.27-825.287-1115.521l-1.594-8.816C185.286 163.833 112.786-237.016-762.678-643.898L-1822.83 608.665 571.922 2635.55l544.338-771.86Z"
fill="#A41C42"
fill-opacity=".2"
/>
</svg>
</div>
<div class="px-4 py-10 sm:px-6 sm:py-28 lg:px-8 xl:px-28 xl:py-32">
<div class="mx-auto max-w-xl lg:mx-0">
<svg viewBox="0 0 71 48" class="h-12" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.043.03a2.96 2.96 0 0 0 .04-.029c-.038-.117-.107-.12-.197-.054l.122.107c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728.374.388.763.768 1.182 1.106 1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zm17.29-19.32c0-.023.001-.045.003-.068l-.006.006.006-.006-.036-.004.021.018.012.053Zm-20 14.744a7.61 7.61 0 0 0-.072-.041.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zm-.072-.041-.008-.034-.008.01.008-.01-.022-.006.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>
<h1 class="text-brand mt-10 flex items-center text-sm font-semibold leading-6">
Phoenix Framework
<small class="bg-brand/5 text-[0.8125rem] ml-3 rounded-full px-2 font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %>
</small>
</h1>
<p class="text-[2rem] mt-4 font-semibold leading-10 tracking-tighter text-zinc-900 text-balance">
Peace of mind from prototype to production.
</p>
<p class="mt-4 text-base leading-7 text-zinc-600">
Build rich, interactive web applications quickly, with less code and fewer moving parts. Join our growing community of developers using Phoenix to craft APIs, HTML5 apps and more, for fun or at scale.
</p>
<div class="flex">
<div class="w-full sm:w-auto">
<div class="mt-10 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
<a
href="https://hexdocs.pm/phoenix/overview.html"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path d="m12 4 10-2v18l-10 2V4Z" fill="#18181B" fill-opacity=".15" />
<path
d="M12 4 2 2v18l10 2m0-18v18m0-18 10-2v18l-10 2"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Guides &amp; Docs
</span>
</a>
<a
href="https://github.com/phoenixframework/phoenix"
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" aria-hidden="true" class="h-6 w-6">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12 0C5.37 0 0 5.506 0 12.303c0 5.445 3.435 10.043 8.205 11.674.6.107.825-.262.825-.585 0-.292-.015-1.261-.015-2.291C6 21.67 5.22 20.346 4.98 19.654c-.135-.354-.72-1.446-1.23-1.738-.42-.23-1.02-.8-.015-.815.945-.015 1.62.892 1.845 1.261 1.08 1.86 2.805 1.338 3.495 1.015.105-.8.42-1.338.765-1.645-2.67-.308-5.46-1.37-5.46-6.075 0-1.338.465-2.446 1.23-3.307-.12-.308-.54-1.569.12-3.26 0 0 1.005-.323 3.3 1.26.96-.276 1.98-.415 3-.415s2.04.139 3 .416c2.295-1.6 3.3-1.261 3.3-1.261.66 1.691.24 2.952.12 3.26.765.861 1.23 1.953 1.23 3.307 0 4.721-2.805 5.767-5.475 6.075.435.384.81 1.122.81 2.276 0 1.645-.015 2.968-.015 3.383 0 .323.225.707.825.585a12.047 12.047 0 0 0 5.919-4.489A12.536 12.536 0 0 0 24 12.304C24 5.505 18.63 0 12 0Z"
fill="#18181B"
/>
</svg>
Source Code
</span>
</a>
<a
href={"https://github.com/phoenixframework/phoenix/blob/v#{Application.spec(:phoenix, :vsn)}/CHANGELOG.md"}
class="group relative rounded-2xl px-6 py-4 text-sm font-semibold leading-6 text-zinc-900 sm:py-6"
>
<span class="absolute inset-0 rounded-2xl bg-zinc-50 transition group-hover:bg-zinc-100 sm:group-hover:scale-105">
</span>
<span class="relative flex items-center gap-4 sm:flex-col">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" class="h-6 w-6">
<path
d="M12 1v6M12 17v6"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12"
cy="12"
r="4"
fill="#18181B"
fill-opacity=".15"
stroke="#18181B"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
Changelog
</span>
</a>
</div>
<div class="mt-10 grid grid-cols-1 gap-y-4 text-sm leading-6 text-zinc-700 sm:grid-cols-2">
<div>
<a
href="https://twitter.com/elixirphoenix"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M5.403 14c5.283 0 8.172-4.617 8.172-8.62 0-.131 0-.262-.008-.391A6.033 6.033 0 0 0 15 3.419a5.503 5.503 0 0 1-1.65.477 3.018 3.018 0 0 0 1.263-1.676 5.579 5.579 0 0 1-1.824.736 2.832 2.832 0 0 0-1.63-.916 2.746 2.746 0 0 0-1.821.319A2.973 2.973 0 0 0 8.076 3.78a3.185 3.185 0 0 0-.182 1.938 7.826 7.826 0 0 1-3.279-.918 8.253 8.253 0 0 1-2.64-2.247 3.176 3.176 0 0 0-.315 2.208 3.037 3.037 0 0 0 1.203 1.836A2.739 2.739 0 0 1 1.56 6.22v.038c0 .7.23 1.377.65 1.919.42.54 1.004.912 1.654 1.05-.423.122-.866.14-1.297.052.184.602.541 1.129 1.022 1.506a2.78 2.78 0 0 0 1.662.598 5.656 5.656 0 0 1-2.007 1.074A5.475 5.475 0 0 1 1 12.64a7.827 7.827 0 0 0 4.403 1.358" />
</svg>
Follow on Twitter
</a>
</div>
<div>
<a
href="https://elixirforum.com"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M8 13.833c3.866 0 7-2.873 7-6.416C15 3.873 11.866 1 8 1S1 3.873 1 7.417c0 1.081.292 2.1.808 2.995.606 1.05.806 2.399.086 3.375l-.208.283c-.285.386-.01.905.465.85.852-.098 2.048-.318 3.137-.81a3.717 3.717 0 0 1 1.91-.318c.263.027.53.041.802.041Z" />
</svg>
Discuss on the Elixir Forum
</a>
</div>
<div>
<a
href="https://web.libera.chat/#elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.356 2.007a.75.75 0 0 1 .637.849l-1.5 10.5a.75.75 0 1 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.637ZM11.356 2.008a.75.75 0 0 1 .637.848l-1.5 10.5a.75.75 0 0 1-1.485-.212l1.5-10.5a.75.75 0 0 1 .848-.636Z"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14 5.25a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75ZM13 10.75a.75.75 0 0 1-.75.75h-9.5a.75.75 0 0 1 0-1.5h9.5a.75.75 0 0 1 .75.75Z"
/>
</svg>
Chat on Libera IRC
</a>
</div>
<div>
<a
href="https://discord.gg/elixir"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 16 16"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M13.545 2.995c-1.02-.46-2.114-.8-3.257-.994a.05.05 0 0 0-.052.024c-.141.246-.297.567-.406.82a12.377 12.377 0 0 0-3.658 0 8.238 8.238 0 0 0-.412-.82.052.052 0 0 0-.052-.024 13.315 13.315 0 0 0-3.257.994.046.046 0 0 0-.021.018C.356 6.063-.213 9.036.066 11.973c.001.015.01.029.02.038a13.353 13.353 0 0 0 3.996 1.987.052.052 0 0 0 .056-.018c.308-.414.582-.85.818-1.309a.05.05 0 0 0-.028-.069 8.808 8.808 0 0 1-1.248-.585.05.05 0 0 1-.005-.084c.084-.062.168-.126.248-.191a.05.05 0 0 1 .051-.007c2.619 1.176 5.454 1.176 8.041 0a.05.05 0 0 1 .053.006c.08.065.164.13.248.192a.05.05 0 0 1-.004.084c-.399.23-.813.423-1.249.585a.05.05 0 0 0-.027.07c.24.457.514.893.817 1.307a.051.051 0 0 0 .056.019 13.31 13.31 0 0 0 4.001-1.987.05.05 0 0 0 .021-.037c.334-3.396-.559-6.345-2.365-8.96a.04.04 0 0 0-.021-.02Zm-8.198 7.19c-.789 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.637 1.587-1.438 1.587Zm5.316 0c-.788 0-1.438-.712-1.438-1.587 0-.874.637-1.586 1.438-1.586.807 0 1.45.718 1.438 1.586 0 .875-.63 1.587-1.438 1.587Z" />
</svg>
Join our Discord server
</a>
</div>
<div>
<a
href="https://fly.io/docs/elixir/getting-started/"
class="group -mx-2 -my-0.5 inline-flex items-center gap-3 rounded-lg px-2 py-0.5 hover:bg-zinc-50 hover:text-zinc-900"
>
<svg
viewBox="0 0 20 20"
aria-hidden="true"
class="h-4 w-4 fill-zinc-400 group-hover:fill-zinc-600"
>
<path d="M1 12.5A4.5 4.5 0 005.5 17H15a4 4 0 001.866-7.539 3.504 3.504 0 00-4.504-4.272A4.5 4.5 0 004.06 8.235 4.502 4.502 0 001 12.5z" />
</svg>
Deploy your application
</a>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -1,84 +0,0 @@
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

@ -1,84 +0,0 @@
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,
socket
|> assign(page: 1, per_page: 20)
|> paginate_contacts(1)}
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
def handle_event("next-page", _, socket) do
IO.puts("NEXT Pageee")
{:noreply, paginate_contacts(socket, socket.assigns.page + 1)}
end
def handle_event("prev-page", %{"_overran" => true}, socket) do
{:noreply, paginate_contacts(socket, 1)}
end
def handle_event("prev-page", _, socket) do
if socket.assigns.page > 1 do
{:noreply, paginate_contacts(socket, socket.assigns.page - 1)}
else
{:noreply, socket}
end
end
defp paginate_contacts(socket, new_page) when new_page >= 1 do
%{per_page: per_page, page: cur_page} = socket.assigns
contacts = Marketing.list_contacts(offset: (new_page - 1) * per_page, limit: per_page)
{contacts, at, limit} =
if new_page >= cur_page do
{contacts, -1, per_page * 3 * -1}
else
{Enum.reverse(contacts), 0, per_page * 3}
end
case contacts do
[] ->
assign(socket, end_of_timeline?: at == -1)
[_ | _] = contacts ->
socket
|> assign(end_of_timeline?: false)
|> assign(:page, new_page)
|> stream(:contacts, contacts, at: at, limit: limit)
end
end
end

View File

@ -1,42 +0,0 @@
<.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}
phx-viewport-top={@page > 1 && "prev-page"}
phx-viewport-bottom={!@end_of_timeline? && "next-page"}
phx-page-loading
>
<: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>
<h3 :if={@end_of_timeline?}>No more contacts!</h3>
<.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

@ -1,21 +0,0 @@
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

@ -1,27 +0,0 @@
<.back navigate={~p"/contacts"}>Back to contacts</.back>
<.header>
Contact
<: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>
<.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

@ -1,72 +0,0 @@
defmodule SendItWeb.MessageLive.FormComponent do
use SendItWeb, :live_component
alias SendIt.Marketing
@impl true
def render(assigns) do
~H"""
<div class="flex-column">
<.header>
<%= @title %>
<:subtitle>Messages will automatically be sent to all contacts.</:subtitle>
</.header>
<.simple_form
for={@form}
id="message-form"
phx-target={@myself}
phx-change="validate"
phx-submit="save"
>
<.input field={@form[:subject]} type="text" label="Subject" />
<.input field={@form[:content]} type="textarea" label="Content" />
<:actions>
<.button data-confirm="This will send to all contacts!" phx-disable-with="Sending...">
Send Message
</.button>
</:actions>
</.simple_form>
</div>
"""
end
@impl true
def update(%{message: message} = assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:form, fn ->
to_form(Marketing.change_message(message))
end)}
end
@impl true
def handle_event("validate", %{"message" => message_params}, socket) do
changeset = Marketing.change_message(socket.assigns.message, message_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"message" => message_params}, socket) do
save_message(socket, socket.assigns.action, message_params)
end
defp save_message(socket, :new, message_params) do
case Marketing.create_message(message_params) do
{:ok, message} ->
[{:ok, _email} | _rest] = Marketing.deliver_message(message)
notify_parent({:saved, message})
{:noreply,
socket
|> put_flash(:info, "Message 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

@ -1,47 +0,0 @@
defmodule SendItWeb.MessageLive.Index do
use SendItWeb, :live_view
alias SendIt.Marketing
alias SendIt.Marketing.Message
@impl true
def mount(_params, _session, socket) do
{:ok, stream(socket, :messages, Marketing.list_messages())}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :edit, %{"id" => id}) do
socket
|> assign(:page_title, "Edit Message")
|> assign(:message, Marketing.get_message!(id))
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Message")
|> assign(:message, %Message{})
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Listing Messages")
|> assign(:message, nil)
end
@impl true
def handle_info({SendItWeb.MessageLive.FormComponent, {:saved, message}}, socket) do
{:noreply, stream_insert(socket, :messages, message, at: 0)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
message = Marketing.get_message!(id)
{:ok, _} = Marketing.delete_message(message)
{:noreply, stream_delete(socket, :messages, message)}
end
end

View File

@ -1,45 +0,0 @@
<.header>
Messages
<:actions>
<.link patch={~p"/messages/new"}>
<.button>New Message</.button>
</.link>
</:actions>
</.header>
<.table
id="messages"
rows={@streams.messages}
row_click={fn {_id, message} -> JS.navigate(~p"/messages/#{message}") end}
>
<:col :let={{_id, message}} label="Subject">
<%= message.subject %>
</:col>
<:col :let={{_id, message}} label="Created">
<%= format_date(message.inserted_at) %>
</:col>
<:action :let={{id, message}}>
<.link
phx-click={JS.push("delete", value: %{id: message.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
<.modal
:if={@live_action in [:new]}
id="message-modal"
show
on_cancel={JS.patch(~p"/messages")}
>
<.live_component
module={SendItWeb.MessageLive.FormComponent}
id={@message.id || :new}
title={@page_title}
action={@live_action}
message={@message}
patch={~p"/messages"}
/>
</.modal>

View File

@ -1,21 +0,0 @@
defmodule SendItWeb.MessageLive.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(:message, Marketing.get_message!(id))}
end
defp page_title(:show), do: "Show Message"
defp page_title(:edit), do: "Edit Message"
end

View File

@ -1,33 +0,0 @@
<.back navigate={~p"/messages"}>Back to messages</.back>
<.header>
Message
<:subtitle>Sent: <%= format_date(@message.inserted_at) %></:subtitle>
</.header>
<.list>
<:item title="Subject">
<%= @message.subject %>
</:item>
<:item title="Content">
<div class="text-content">
<%= raw(@message.content) %>
</div>
</:item>
</.list>
<.modal
:if={@live_action == :edit}
id="message-modal"
show
on_cancel={JS.patch(~p"/messages/#{@message}")}
>
<.live_component
module={SendItWeb.MessageLive.FormComponent}
id={@message.id}
title={@page_title}
action={@live_action}
message={@message}
patch={~p"/messages/#{@message}"}
/>
</.modal>

View File

@ -1,56 +0,0 @@
defmodule SendItWeb.SubscriptionLive do
use SendItWeb, :live_view
alias SendIt.Marketing
@impl true
def mount(params, _session, socket) do
{:ok, assign_form(socket, params)}
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,
socket
|> assign_form(%{})
|> put_flash(:info, "You have been unsubscribed from all emails.")}
{:error, reason} ->
{:noreply, put_flash(socket, :error, reason)}
end
end
defp assign_form(socket, params) do
email = handle_params(params)
assign(socket, :form, to_form(%{"email" => email}))
end
defp handle_params(%{"email" => email}), do: email
defp handle_params(_params), do: ""
end

View File

@ -21,7 +21,8 @@ defmodule SendItWeb.UserConfirmationInstructionsLive do
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/log_in"}>Log in</.link>
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""

View File

@ -16,7 +16,8 @@ defmodule SendItWeb.UserConfirmationLive do
</.simple_form>
<p class="text-center mt-4">
<.link href={~p"/users/log_in"}>Log in</.link>
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""

View File

@ -20,7 +20,8 @@ defmodule SendItWeb.UserForgotPasswordLive do
</:actions>
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/log_in"}>Log in</.link>
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""

View File

@ -6,6 +6,13 @@ defmodule SendItWeb.UserLoginLive do
<div class="mx-auto max-w-sm">
<.header class="text-center">
Log in to account
<:subtitle>
Don't have an account?
<.link navigate={~p"/users/register"} class="font-semibold text-brand hover:underline">
Sign up
</.link>
for an account now.
</:subtitle>
</.header>
<.simple_form for={@form} id="login_form" action={~p"/users/log_in"} phx-update="ignore">

View File

@ -31,7 +31,8 @@ defmodule SendItWeb.UserResetPasswordLive do
</.simple_form>
<p class="text-center text-sm mt-4">
<.link href={~p"/users/log_in"}>Log in</.link>
<.link href={~p"/users/register"}>Register</.link>
| <.link href={~p"/users/log_in"}>Log in</.link>
</p>
</div>
"""

View File

@ -21,7 +21,6 @@ defmodule SendItWeb.Router do
pipe_through :browser
get "/", PageController, :home
live "/subscription", SubscriptionLive
end
# Other scopes may use custom stacks.
@ -53,7 +52,7 @@ defmodule SendItWeb.Router do
live_session :redirect_if_user_is_authenticated,
on_mount: [{SendItWeb.UserAuth, :redirect_if_user_is_authenticated}] do
# live "/users/register", UserRegistrationLive, :new
live "/users/register", UserRegistrationLive, :new
live "/users/log_in", UserLoginLive, :new
live "/users/reset_password", UserForgotPasswordLive, :new
live "/users/reset_password/:token", UserResetPasswordLive, :edit
@ -67,15 +66,6 @@ defmodule SendItWeb.Router do
live_session :require_authenticated_user,
on_mount: [{SendItWeb.UserAuth, :ensure_authenticated}] do
live "/messages", MessageLive.Index, :index
live "/messages/new", MessageLive.Index, :new
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/confirm_email/:token", UserSettingsLive, :confirm_email
end

View File

@ -40,7 +40,7 @@ defmodule SendIt.MixProject do
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
# TODO bump on release to {:phoenix_live_view, "~> 1.0.0"},
{:phoenix_live_view, "~> 1.0.0-rc.7", override: true},
{:phoenix_live_view, "~> 1.0.0-rc.1", override: true},
{:floki, ">= 0.30.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
@ -50,8 +50,7 @@ defmodule SendIt.MixProject do
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.1.1"},
{:bandit, "~> 1.5"},
{:multipart, "~> 0.4.0"}
{:bandit, "~> 1.5"}
]
end

View File

@ -1,14 +1,11 @@
%{
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.2.0", "feab711974beba4cb348147170346fe097eea2e840db4e012a145e180ed4ab75", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "563e92a6c77d667b19c5f4ba17ab6d440a085696bdf4c68b9b0f5b30bc5422b8"},
"castore": {:hex, :castore, "1.0.10", "43bbeeac820f16c89f79721af1b3e092399b3a1ecc8df1a472738fd853574911", [:mix], [], "hexpm", "1b0b7ea14d889d9ea21202c43a4fa015eb913021cb535e8ed91946f4b77a8848"},
"comeonin": {:hex, :comeonin, "5.5.0", "364d00df52545c44a139bad919d7eacb55abf39e86565878e17cebb787977368", [:mix], [], "hexpm", "6287fc3ba0aad34883cbe3f7949fc1d1e738e5ccdce77165bc99490aa69f47fb"},
"db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"},
"decimal": {:hex, :decimal, "2.2.0", "df3d06bb9517e302b1bd265c1e7f16cda51547ad9d99892049340841f3e15836", [:mix], [], "hexpm", "af8daf87384b51b7e611fb1a1f2c4d4876b65ef968fa8bd3adf44cff401c7f21"},
"dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"},
"ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"},
"ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.8.2", "5f379dfa383ef482b738e7771daf238b2d1cfb0222bef9d3b20d4c8f06c7a7ac", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "558a8a08ed78eb820efbfda1de196569d8bfa9b51e8371a1934fbb31345feda7"},
"file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"},
"finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"},
@ -18,7 +15,6 @@
"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"},
"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_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"},

View File

@ -1,15 +0,0 @@
defmodule SendIt.Repo.Migrations.CreateContacts do
use Ecto.Migration
def change do
create table(:contacts) do
add :name, :string
add :email, :string
add :subscribed, :boolean, default: true, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:contacts, [:email])
end
end

View File

@ -1,12 +0,0 @@
defmodule SendIt.Repo.Migrations.CreateMessages do
use Ecto.Migration
def change do
create table(:messages) do
add :subject, :string
add :content, :text
timestamps(type: :utc_datetime)
end
end
end

View File

@ -1,12 +0,0 @@
defmodule SendIt.Repo.Migrations.CreateContactsMessages do
use Ecto.Migration
def change do
create table(:contacts_messages, primary_key: false) do
add :contact_id, references(:contacts, on_delete: :delete_all)
add :message_id, references(:messages, on_delete: :delete_all)
end
create unique_index(:contacts_messages, [:contact_id, :message_id])
end
end

View File

@ -1,13 +0,0 @@
#!/bin/sh
# configure node for distributed erlang with IPV6 support
export ERL_AFLAGS="-proto_dist inet6_tcp"
export ECTO_IPV6="true"
export DNS_CLUSTER_QUERY="${FLY_APP_NAME}.internal"
export RELEASE_DISTRIBUTION="name"
export RELEASE_NODE="${FLY_APP_NAME}-${FLY_IMAGE_REF##*-}@${FLY_PRIVATE_IP}"
# Uncomment to send crash dumps to stderr
# This can be useful for debugging, but may log sensitive information
# export ERL_CRASH_DUMP=/dev/stderr
# export ERL_CRASH_DUMP_BYTES=4096

View File

@ -1,5 +0,0 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
exec ./send_it eval SendIt.Release.migrate

View File

@ -1 +0,0 @@
call "%~dp0\send_it" eval SendIt.Release.migrate

View File

@ -1,5 +0,0 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./send_it start

View File

@ -1,2 +0,0 @@
set PHX_SERVER=true
call "%~dp0\send_it" start

View File

@ -1,119 +0,0 @@
defmodule SendIt.MarketingTest do
use SendIt.DataCase
alias SendIt.Marketing
describe "contacts" do
alias SendIt.Marketing.Contact
import SendIt.MarketingFixtures
@invalid_attrs %{name: nil, email: nil, subscribed: nil}
test "list_contacts/0 returns all contacts" do
contact = contact_fixture()
assert Marketing.list_contacts() == [contact]
end
test "get_contact!/1 returns the contact with given id" do
contact = contact_fixture()
assert Marketing.get_contact!(contact.id) == contact
end
test "create_contact/1 with valid data creates a contact" do
valid_attrs = %{name: "some name", email: "some email", subscribed: true}
assert {:ok, %Contact{} = contact} = Marketing.create_contact(valid_attrs)
assert contact.name == "some name"
assert contact.email == "some email"
assert contact.subscribed == true
end
test "create_contact/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Marketing.create_contact(@invalid_attrs)
end
test "update_contact/2 with valid data updates the contact" do
contact = contact_fixture()
update_attrs = %{name: "some updated name", email: "some updated email", subscribed: false}
assert {:ok, %Contact{} = contact} = Marketing.update_contact(contact, update_attrs)
assert contact.name == "some updated name"
assert contact.email == "some updated email"
assert contact.subscribed == false
end
test "update_contact/2 with invalid data returns error changeset" do
contact = contact_fixture()
assert {:error, %Ecto.Changeset{}} = Marketing.update_contact(contact, @invalid_attrs)
assert contact == Marketing.get_contact!(contact.id)
end
test "delete_contact/1 deletes the contact" do
contact = contact_fixture()
assert {:ok, %Contact{}} = Marketing.delete_contact(contact)
assert_raise Ecto.NoResultsError, fn -> Marketing.get_contact!(contact.id) end
end
test "change_contact/1 returns a contact changeset" do
contact = contact_fixture()
assert %Ecto.Changeset{} = Marketing.change_contact(contact)
end
end
describe "messages" do
alias SendIt.Marketing.Message
import SendIt.MarketingFixtures
@invalid_attrs %{subject: nil, content: nil}
test "list_messages/0 returns all messages" do
message = message_fixture()
assert Marketing.list_messages() == [message]
end
test "get_message!/1 returns the message with given id" do
message = message_fixture()
assert Marketing.get_message!(message.id) == message
end
test "create_message/1 with valid data creates a message" do
valid_attrs = %{subject: "some subject", content: "some content"}
assert {:ok, %Message{} = message} = Marketing.create_message(valid_attrs)
assert message.subject == "some subject"
assert message.content == "some content"
end
test "create_message/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Marketing.create_message(@invalid_attrs)
end
test "update_message/2 with valid data updates the message" do
message = message_fixture()
update_attrs = %{subject: "some updated subject", content: "some updated content"}
assert {:ok, %Message{} = message} = Marketing.update_message(message, update_attrs)
assert message.subject == "some updated subject"
assert message.content == "some updated content"
end
test "update_message/2 with invalid data returns error changeset" do
message = message_fixture()
assert {:error, %Ecto.Changeset{}} = Marketing.update_message(message, @invalid_attrs)
assert message == Marketing.get_message!(message.id)
end
test "delete_message/1 deletes the message" do
message = message_fixture()
assert {:ok, %Message{}} = Marketing.delete_message(message)
assert_raise Ecto.NoResultsError, fn -> Marketing.get_message!(message.id) end
end
test "change_message/1 returns a message changeset" do
message = message_fixture()
assert %Ecto.Changeset{} = Marketing.change_message(message)
end
end
end

View File

@ -1,113 +0,0 @@
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

View File

@ -1,42 +0,0 @@
defmodule SendIt.MarketingFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `SendIt.Marketing` context.
"""
@doc """
Generate a unique contact email.
"""
def unique_contact_email, do: "some email#{System.unique_integer([:positive])}"
@doc """
Generate a contact.
"""
def contact_fixture(attrs \\ %{}) do
{:ok, contact} =
attrs
|> Enum.into(%{
email: unique_contact_email(),
name: "some name",
subscribed: true
})
|> SendIt.Marketing.create_contact()
contact
end
@doc """
Generate a message.
"""
def message_fixture(attrs \\ %{}) do
{:ok, message} =
attrs
|> Enum.into(%{
content: "some content",
subject: "some subject"
})
|> SendIt.Marketing.create_message()
message
end
end