Initial commit: RateMyClient™ Phoenix application

Features:
- User registration and authentication with email/password
- Admin login with username-based authentication (separate from regular users)
- Review system for contractors to rate clients
- Star rating system with review forms
- Client identification with private data protection
- Contractor registration with document verification
- Admin dashboard for review management
- Contact form (demo, non-functional)
- Responsive navigation with DaisyUI components
- Docker Compose setup for production deployment
- PostgreSQL database with Ecto migrations
- High Vis color scheme (dark background with safety orange/green)

Admin credentials: username: admin, password: admin123

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kevin Sivic 2025-11-28 21:30:50 -05:00
commit 9ece312442
89 changed files with 7085 additions and 0 deletions

View file

@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(iex -S mix run -e 'MyFirstElixirVibeCode.Accounts.create_user(%{username: \"\"admin\"\", password: \"\"admin123\"\", is_admin: true}) |> IO.inspect()')"
],
"deny": [],
"ask": []
}
}

41
.dockerignore Normal file
View file

@ -0,0 +1,41 @@
# Git
.git
.gitignore
# Build artifacts
_build
deps
*.ez
# Environment files
.env
.env.*
# Dependencies
node_modules
assets/node_modules
# Database
*.db
*.sqlite3
# Logs
*.log
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Phoenix artifacts
priv/static/assets
# Docker
docker-compose.yml
Dockerfile
.dockerignore

6
.formatter.exs Normal file
View file

@ -0,0 +1,6 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

37
.gitignore vendored Normal file
View file

@ -0,0 +1,37 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
my_first_elixir_vibe_code-*.tar
# Ignore assets that are produced by build tools.
/priv/static/assets/
# Ignore digested assets cache.
/priv/static/cache_manifest.json
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
erlang 28.1
elixir 1.19.0-rc.0-otp-28

334
AGENTS.md Normal file
View file

@ -0,0 +1,334 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
### JS and CSS guidelines
- **Use Tailwind CSS classes and custom CSS rules** to create polished, responsive, and visually stunning interfaces.
- Tailwindcss v4 **no longer needs a tailwind.config.js** and uses a new import syntax in `app.css`:
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_app_web";
- **Always use and maintain this import syntax** in the app.css file for projects generated with `phx.new`
- **Never** use `@apply` when writing raw css
- **Always** manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design
- Out of the box **only the app.js and app.css bundles are supported**
- You cannot reference an external vendor'd script `src` or link `href` in the layouts
- You must import the vendor deps into app.js and app.css to use them
- **Never write inline <script>custom js</script> tags within templates**
### UI/UX & design guidelines
- **Produce world-class UI designs** with a focus on usability, aesthetics, and modern design principles
- Implement **subtle micro-interactions** (e.g., button hover effects, and smooth transitions)
- Ensure **clean typography, spacing, and layout balance** for a refined, premium look
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
<!-- phoenix:ecto-end -->
<!-- phoenix:html-start -->
## Phoenix HTML guidelines
- Phoenix templates **always** use `~H` or .html.heex files (known as HEEx), **never** use `~E`
- **Always** use the imported `Phoenix.Component.form/1` and `Phoenix.Component.inputs_for/1` function to build forms. **Never** use `Phoenix.HTML.form_for` or `Phoenix.HTML.inputs_for` as they are outdated
- When building forms **always** use the already imported `Phoenix.Component.to_form/2` (`assign(socket, form: to_form(...))` and `<.form for={@form} id="msg-form">`), then access those forms in the template via `@form[:field]`
- **Always** add unique DOM IDs to key elements (like forms, buttons, etc) when writing templates, these IDs can later be used in tests (`<.form for={@form} id="product-form">`)
- For "app wide" template imports, you can import/alias into the `my_app_web.ex`'s `html_helpers` block, so they will be available to all LiveViews, LiveComponent's, and all modules that do `use MyAppWeb, :html` (replace "my_app" by the actual app name)
- Elixir supports `if/else` but **does NOT support `if/else if` or `if/elsif`. **Never use `else if` or `elseif` in Elixir**, **always** use `cond` or `case` for multiple conditionals.
**Never do this (invalid)**:
<%= if condition do %>
...
<% else if other_condition %>
...
<% end %>
Instead **always** do this:
<%= cond do %>
<% condition -> %>
...
<% condition2 -> %>
...
<% true -> %>
...
<% end %>
- HEEx require special tag annotation if you want to insert literal curly's like `{` or `}`. If you want to show a textual code snippet on the page in a `<pre>` or `<code>` block you *must* annotate the parent tag with `phx-no-curly-interpolation`:
<code phx-no-curly-interpolation>
let obj = {key: "val"}
</code>
Within `phx-no-curly-interpolation` annotated tags, you can use `{` and `}` without escaping them, and dynamic Elixir expressions can still be used with `<%= ... %>` syntax
- HEEx class attrs support lists, but you must **always** use list `[...]` syntax. You can use the class list syntax to conditionally add classes, **always do this for multiple class values**:
<a class={[
"px-2 text-white",
@some_flag && "py-5",
if(@other_condition, do: "border-red-500", else: "border-blue-100"),
...
]}>Text</a>
and **always** wrap `if`'s inside `{...}` expressions with parens, like done above (`if(@other_condition, do: "...", else: "...")`)
and **never** do this, since it's invalid (note the missing `[` and `]`):
<a class={
"px-2 text-white",
@some_flag && "py-5"
}> ...
=> Raises compile syntax error on invalid HEEx attr syntax
- **Never** use `<% Enum.each %>` or non-for comprehensions for generating template content, instead **always** use `<%= for item <- @collection do %>`
- HEEx HTML comments use `<%!-- comment --%>`. **Always** use the HEEx HTML comment syntax for template comments (`<%!-- comment --%>`)
- HEEx allows interpolation via `{...}` and `<%= ... %>`, but the `<%= %>` **only** works within tag bodies. **Always** use the `{...}` syntax for interpolation within tag attributes, and for interpolation of values within tag bodies. **Always** interpolate block constructs (if, cond, case, for) within tag bodies using `<%= ... %>`.
**Always** do this:
<div id={@id}>
{@my_assign}
<%= if @some_block_condition do %>
{@another_assign}
<% end %>
</div>
and **Never** do this the program will terminate with a syntax error:
<%!-- THIS IS INVALID NEVER EVER DO THIS --%>
<div id="<%= @invalid_interpolation %>">
{if @invalid_block_construct do}
{end}
</div>
<!-- phoenix:html-end -->
<!-- phoenix:liveview-start -->
## Phoenix LiveView guidelines
- **Never** use the deprecated `live_redirect` and `live_patch` functions, instead **always** use the `<.link navigate={href}>` and `<.link patch={href}>` in templates, and `push_navigate` and `push_patch` functions LiveViews
- **Avoid LiveComponent's** unless you have a strong, specific need for them
- LiveViews should be named like `AppWeb.WeatherLive`, with a `Live` suffix. When you go to add LiveView routes to the router, the default `:browser` scope is **already aliased** with the `AppWeb` module, so you can just do `live "/weather", WeatherLive`
- Remember anytime you use `phx-hook="MyHook"` and that js hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Never** write embedded `<script>` tags in HEEx. Instead always write your scripts and hooks in the `assets/js` directory and integrate them with the `assets/js/app.js` file
### LiveView streams
- **Always** use LiveView streams for collections for assigning regular lists to avoid memory ballooning and runtime termination with the following operations:
- basic append of N items - `stream(socket, :messages, [new_msg])`
- resetting stream with new items - `stream(socket, :messages, [new_msg], reset: true)` (e.g. for filtering items)
- prepend to stream - `stream(socket, :messages, [new_msg], at: -1)`
- deleting items - `stream_delete(socket, :messages, msg)`
- When using the `stream/3` interfaces in the LiveView, the LiveView template must 1) always set `phx-update="stream"` on the parent element, with a DOM id on the parent element like `id="messages"` and 2) consume the `@streams.stream_name` collection and use the id as the DOM id for each child. For a call like `stream(socket, :messages, [new_msg])` in the LiveView, the template would be:
<div id="messages" phx-update="stream">
<div :for={{id, msg} <- @streams.messages} id={id}>
{msg.text}
</div>
</div>
- LiveView streams are *not* enumerable, so you cannot use `Enum.filter/2` or `Enum.reject/2` on them. Instead, if you want to filter, prune, or refresh a list of items on the UI, you **must refetch the data and re-stream the entire stream collection, passing reset: true**:
def handle_event("filter", %{"filter" => filter}, socket) do
# re-fetch the messages based on the filter
messages = list_messages(filter)
{:noreply,
socket
|> assign(:messages_empty?, messages == [])
# reset the stream with the new messages
|> stream(:messages, messages, reset: true)}
end
- LiveView streams *do not support counting or empty states*. If you need to display a count, you must track it using a separate assign. For empty states, you can use Tailwind classes:
<div id="tasks" phx-update="stream">
<div class="hidden only:block">No tasks yet</div>
<div :for={{id, task} <- @stream.tasks} id={id}>
{task.name}
</div>
</div>
The above only works if the empty state is the only HTML block alongside the stream for-comprehension.
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView tests
- `Phoenix.LiveViewTest` module and `LazyHTML` (included) for making your assertions
- Form tests are driven by `Phoenix.LiveViewTest`'s `render_submit/2` and `render_change/2` functions
- Come up with a step-by-step test plan that splits major test cases into small, isolated files. You may start with simpler tests that verify content exists, gradually add interaction tests
- **Always reference the key element IDs you added in the LiveView templates in your tests** for `Phoenix.LiveViewTest` functions like `element/2`, `has_element/2`, selectors, etc
- **Never** tests again raw HTML, **always** use `element/2`, `has_element/2`, and similar: `assert has_element?(view, "#my-form")`
- Instead of relying on testing text content, which can change, favor testing for the presence of key elements
- Focus on testing outcomes rather than implementation details
- Be aware that `Phoenix.Component` functions like `<.form>` might produce different HTML than expected. Test against the output HTML structure, not your mental model of what you expect it to be
- When facing test failures with element selectors, add debug statements to print the actual HTML, but use `LazyHTML` selectors to limit the output, ie:
html = render(view)
document = LazyHTML.from_fragment(html)
matches = LazyHTML.filter(document, "your-complex-selector")
IO.inspect(matches, label: "Matches")
### Form handling
#### Creating a form from params
If you want to create a form based on `handle_event` params:
def handle_event("submitted", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
When you pass a map to `to_form/1`, it assumes said map contains the form params, which are expected to have string keys.
You can also specify a name to nest the params:
def handle_event("submitted", %{"user" => user_params}, socket) do
{:noreply, assign(socket, form: to_form(user_params, as: :user))}
end
#### Creating a form from changesets
When using changesets, the underlying data, form params, and errors are retrieved from it. The `:as` option is automatically computed too. E.g. if you have a user schema:
defmodule MyApp.Users.User do
use Ecto.Schema
...
end
And then you create a changeset that you pass to `to_form`:
%MyApp.Users.User{}
|> Ecto.Changeset.change()
|> to_form()
Once the form is submitted, the params will be available under `%{"user" => user_params}`.
In the template, the form form assign can be passed to the `<.form>` function component:
<.form for={@form} id="todo-form" phx-change="validate" phx-submit="save">
<.input field={@form[:field]} type="text" />
</.form>
Always give the form an explicit, unique DOM ID, like `id="todo-form"`.
#### Avoiding form errors
**Always** use a form assigned via `to_form/2` in the LiveView, and the `<.input>` component in the template. In the template **always access forms this**:
<%!-- ALWAYS do this (valid) --%>
<.form for={@form} id="my-form">
<.input field={@form[:field]} type="text" />
</.form>
And **never** do this:
<%!-- NEVER do this (invalid) --%>
<.form for={@changeset} id="my-form">
<.input field={@changeset[:field]} type="text" />
</.form>
- You are FORBIDDEN from accessing the changeset in the template as it will cause errors
- **Never** use `<.form let={f} ...>` in the template, instead **always use `<.form for={@form} ...>`**, then drive all form references from the form assign as in `@form[:field]`. The UI should **always** be driven by a `to_form/2` assigned in the LiveView module that is derived from a changeset
<!-- phoenix:liveview-end -->
<!-- usage-rules-end -->

123
DOCKER_README.md Normal file
View file

@ -0,0 +1,123 @@
# RateMyClient - Docker Setup
This guide will help you run the RateMyClient Phoenix application using Docker Compose.
## Prerequisites
- Docker Desktop installed and running
- Docker Compose (included with Docker Desktop)
## Quick Start
1. **Build and start the containers:**
```bash
docker-compose up --build
```
This will:
- Build the Phoenix application Docker image
- Start PostgreSQL database
- Create the database
- Run migrations
- Create an admin user
- Start the Phoenix server
2. **Access the application:**
Open your browser and navigate to: http://localhost:4000
## Default Admin Credentials
- **Username:** `admin`
- **Password:** `admin123`
- **Admin Login:** http://localhost:4000/admin/login
## Useful Commands
### Start containers (detached mode)
```bash
docker-compose up -d
```
### Stop containers
```bash
docker-compose down
```
### View logs
```bash
docker-compose logs -f web
```
### Rebuild after code changes
```bash
docker-compose up --build
```
### Access the Phoenix console
```bash
docker-compose exec web bin/my_first_elixir_vibe_code remote
```
### Run migrations manually
```bash
docker-compose exec web bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.migrate()"
```
### Access PostgreSQL
```bash
docker-compose exec db psql -U postgres -d my_first_elixir_vibe_code_dev
```
## Volumes
The application uses a persistent volume for PostgreSQL data:
- `postgres_data`: Stores database data
To completely reset the database:
```bash
docker-compose down -v
docker-compose up --build
```
## Environment Variables
You can customize the application by modifying the environment variables in `docker-compose.yml`:
- `DATABASE_URL`: PostgreSQL connection string
- `SECRET_KEY_BASE`: Secret key for Phoenix (generate with `mix phx.gen.secret`)
- `PHX_HOST`: Hostname for the application
- `PORT`: Port to run the application on
## Troubleshooting
### Port already in use
If port 4000 is already in use, change the port mapping in `docker-compose.yml`:
```yaml
ports:
- "8080:4000" # Access via http://localhost:8080
```
### Database connection issues
Make sure the database container is healthy:
```bash
docker-compose ps
```
### Clean rebuild
```bash
docker-compose down -v
docker system prune -a
docker-compose up --build
```
## Production Notes
This Docker setup is configured for production use. For actual production deployment:
1. Change the `SECRET_KEY_BASE` to a new secure value
2. Update database credentials in `docker-compose.yml`
3. Consider using environment files (.env) instead of hardcoded values
4. Set up SSL/TLS termination (nginx, traefik, etc.)
5. Configure proper backup strategies for the database

64
Dockerfile Normal file
View file

@ -0,0 +1,64 @@
# Build stage
FROM elixir:1.19.0-rc.0-otp-28-alpine AS build
# Install build dependencies
RUN apk add --no-cache build-base git nodejs npm
# 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 prod
RUN mix deps.compile
# Copy application files
COPY config config
COPY priv priv
COPY lib lib
COPY assets assets
# Compile assets
RUN npm ci --prefix ./assets
RUN npm run deploy --prefix ./assets
RUN mix assets.deploy
# Compile the project
RUN mix compile
# Build release
RUN mix release
# App stage
FROM alpine:3.19 AS app
# Install runtime dependencies
RUN apk add --no-cache libstdc++ openssl ncurses-libs
WORKDIR /app
# Copy the release from build stage
COPY --from=build /app/_build/prod/rel/my_first_elixir_vibe_code ./
# Copy entrypoint script
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Create a non-root user
RUN addgroup -g 1000 phoenix && \
adduser -D -u 1000 -G phoenix phoenix && \
chown -R phoenix:phoenix /app
USER phoenix
EXPOSE 4000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["bin/my_first_elixir_vibe_code", "start"]

18
README.md Normal file
View file

@ -0,0 +1,18 @@
# MyFirstElixirVibeCode
To start your Phoenix server:
* Run `mix setup` to install and setup dependencies
* Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
## Learn more
* Official website: https://www.phoenixframework.org/
* Guides: https://hexdocs.pm/phoenix/overview.html
* Docs: https://hexdocs.pm/phoenix
* Forum: https://elixirforum.com/c/phoenix-forum
* Source: https://github.com/phoenixframework/phoenix

105
assets/css/app.css Normal file
View file

@ -0,0 +1,105 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/my_first_elixir_vibe_code_web";
/* A Tailwind plugin that makes "hero-#{ICON}" classes available.
The heroicons installation itself is managed by your mix.exs */
@plugin "../vendor/heroicons";
/* daisyUI Tailwind Plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
Make sure to look at the daisyUI changelog: https://daisyui.com/docs/changelog/ */
@plugin "../vendor/daisyui" {
themes: false;
}
/* daisyUI theme plugin. You can update this file by fetching the latest version with:
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
We ship with two themes, a light one inspired on Phoenix colors and a dark one inspired
on Elixir colors. Build your own at: https://daisyui.com/theme-generator/ */
@plugin "../vendor/daisyui-theme" {
name: "dark";
default: true;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(15% 0.01 252);
--color-base-200: oklch(12% 0.01 252);
--color-base-300: oklch(8% 0.01 252);
--color-base-content: oklch(98% 0 0);
--color-primary: oklch(75% 0.25 35);
--color-primary-content: oklch(10% 0 0);
--color-secondary: oklch(72% 0.28 130);
--color-secondary-content: oklch(10% 0 0);
--color-accent: oklch(78% 0.30 130);
--color-accent-content: oklch(10% 0 0);
--color-neutral: oklch(25% 0.02 252);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(70% 0.20 230);
--color-info-content: oklch(10% 0 0);
--color-success: oklch(75% 0.28 140);
--color-success-content: oklch(10% 0 0);
--color-warning: oklch(80% 0.30 80);
--color-warning-content: oklch(10% 0 0);
--color-error: oklch(65% 0.30 25);
--color-error-content: oklch(98% 0 0);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 2px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(96% 0.001 286.375);
--color-base-300: oklch(92% 0.004 286.32);
--color-base-content: oklch(21% 0.006 285.885);
--color-primary: oklch(70% 0.213 47.604);
--color-primary-content: oklch(98% 0.016 73.684);
--color-secondary: oklch(55% 0.027 264.364);
--color-secondary-content: oklch(98% 0.002 247.839);
--color-accent: oklch(0% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(44% 0.017 285.786);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(62% 0.214 259.815);
--color-info-content: oklch(97% 0.014 254.604);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(98% 0.014 180.72);
--color-warning: oklch(66% 0.179 58.318);
--color-warning-content: oklch(98% 0.022 95.277);
--color-error: oklch(58% 0.253 17.585);
--color-error-content: oklch(96% 0.015 12.422);
--radius-selector: 0.25rem;
--radius-field: 0.25rem;
--radius-box: 0.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
/* Add variants based on LiveView classes */
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
/* Use the data attribute for dark mode */
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
/* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents }
/* This file is for your main application CSS */

83
assets/js/app.js Normal file
View file

@ -0,0 +1,83 @@
// If you want to use Phoenix channels, run `mix help phx.gen.channel`
// to get started and then uncomment the line below.
// import "./user_socket.js"
// You can include dependencies in two ways.
//
// The simplest option is to put them in assets/vendor and
// import them using relative paths:
//
// import "../vendor/some-package.js"
//
// Alternatively, you can `npm install some-package --prefix assets` and import
// them using a path starting with the package name:
//
// import "some-package"
//
// If you have dependencies that try to import CSS, esbuild will generate a separate `app.css` file.
// To load it, simply add a second `<link>` to your `root.html.heex` file.
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix"
import {LiveSocket} from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/my_first_elixir_vibe_code"
import topbar from "../vendor/topbar"
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks},
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// connect if there are any LiveViews on the page
liveSocket.connect()
// expose liveSocket on window for web console debug logs and latency simulation:
// >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim()
window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload
// development features:
//
// 1. stream server logs to the browser console
// 2. click on elements to jump to their definitions in your code editor
//
if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => {
// Enable server log streaming to client.
// Disable with reloader.disableServerLogs()
reloader.enableServerLogs()
// Open configured PLUG_EDITOR at file:line of the clicked element's HEEx component
//
// * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location
let keyDown
window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", e => keyDown = null)
window.addEventListener("click", e => {
if(keyDown === "c"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){
e.preventDefault()
e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target)
}
}, true)
window.liveReloader = reloader
})
}

32
assets/tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["../deps/*"]
},
"allowJs": true,
"noEmit": true
},
"include": ["js/**/*"]
}

124
assets/vendor/daisyui-theme.js vendored Normal file

File diff suppressed because one or more lines are too long

1031
assets/vendor/daisyui.js vendored Normal file

File diff suppressed because one or more lines are too long

43
assets/vendor/heroicons.js vendored Normal file
View file

@ -0,0 +1,43 @@
const plugin = require("tailwindcss/plugin")
const fs = require("fs")
const path = require("path")
module.exports = plugin(function({matchComponents, theme}) {
let iconsDir = path.join(__dirname, "../../deps/heroicons/optimized")
let values = {}
let icons = [
["", "/24/outline"],
["-solid", "/24/solid"],
["-mini", "/20/solid"],
["-micro", "/16/solid"]
]
icons.forEach(([suffix, dir]) => {
fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
let name = path.basename(file, ".svg") + suffix
values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
})
})
matchComponents({
"hero": ({name, fullPath}) => {
let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
content = encodeURIComponent(content)
let size = theme("spacing.6")
if (name.endsWith("-mini")) {
size = theme("spacing.5")
} else if (name.endsWith("-micro")) {
size = theme("spacing.4")
}
return {
[`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
"-webkit-mask": `var(--hero-${name})`,
"mask": `var(--hero-${name})`,
"mask-repeat": "no-repeat",
"background-color": "currentColor",
"vertical-align": "middle",
"display": "inline-block",
"width": size,
"height": size
}
}
}, {values})
})

138
assets/vendor/topbar.js vendored Normal file
View file

@ -0,0 +1,138 @@
/**
* @license MIT
* topbar 3.0.0
* http://buunguyen.github.io/topbar
* Copyright (c) 2024 Buu Nguyen
*/
(function (window, document) {
"use strict";
var canvas,
currentProgress,
showing,
progressTimerId = null,
fadeTimerId = null,
delayTimerId = null,
addEvent = function (elem, type, handler) {
if (elem.addEventListener) elem.addEventListener(type, handler, false);
else if (elem.attachEvent) elem.attachEvent("on" + type, handler);
else elem["on" + type] = handler;
},
options = {
autoRun: true,
barThickness: 3,
barColors: {
0: "rgba(26, 188, 156, .9)",
".25": "rgba(52, 152, 219, .9)",
".50": "rgba(241, 196, 15, .9)",
".75": "rgba(230, 126, 34, .9)",
"1.0": "rgba(211, 84, 0, .9)",
},
shadowBlur: 10,
shadowColor: "rgba(0, 0, 0, .6)",
className: null,
},
repaint = function () {
canvas.width = window.innerWidth;
canvas.height = options.barThickness * 5; // need space for shadow
var ctx = canvas.getContext("2d");
ctx.shadowBlur = options.shadowBlur;
ctx.shadowColor = options.shadowColor;
var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0);
for (var stop in options.barColors)
lineGradient.addColorStop(stop, options.barColors[stop]);
ctx.lineWidth = options.barThickness;
ctx.beginPath();
ctx.moveTo(0, options.barThickness / 2);
ctx.lineTo(
Math.ceil(currentProgress * canvas.width),
options.barThickness / 2
);
ctx.strokeStyle = lineGradient;
ctx.stroke();
},
createCanvas = function () {
canvas = document.createElement("canvas");
var style = canvas.style;
style.position = "fixed";
style.top = style.left = style.right = style.margin = style.padding = 0;
style.zIndex = 100001;
style.display = "none";
if (options.className) canvas.classList.add(options.className);
addEvent(window, "resize", repaint);
},
topbar = {
config: function (opts) {
for (var key in opts)
if (options.hasOwnProperty(key)) options[key] = opts[key];
},
show: function (delay) {
if (showing) return;
if (delay) {
if (delayTimerId) return;
delayTimerId = setTimeout(() => topbar.show(), delay);
} else {
showing = true;
if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId);
if (!canvas) createCanvas();
if (!canvas.parentElement) document.body.appendChild(canvas);
canvas.style.opacity = 1;
canvas.style.display = "block";
topbar.progress(0);
if (options.autoRun) {
(function loop() {
progressTimerId = window.requestAnimationFrame(loop);
topbar.progress(
"+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2)
);
})();
}
}
},
progress: function (to) {
if (typeof to === "undefined") return currentProgress;
if (typeof to === "string") {
to =
(to.indexOf("+") >= 0 || to.indexOf("-") >= 0
? currentProgress
: 0) + parseFloat(to);
}
currentProgress = to > 1 ? 1 : to;
repaint();
return currentProgress;
},
hide: function () {
clearTimeout(delayTimerId);
delayTimerId = null;
if (!showing) return;
showing = false;
if (progressTimerId != null) {
window.cancelAnimationFrame(progressTimerId);
progressTimerId = null;
}
(function loop() {
if (topbar.progress("+.1") >= 1) {
canvas.style.opacity -= 0.05;
if (canvas.style.opacity <= 0.05) {
canvas.style.display = "none";
fadeTimerId = null;
return;
}
}
fadeTimerId = window.requestAnimationFrame(loop);
})();
},
};
if (typeof module === "object" && typeof module.exports === "object") {
module.exports = topbar;
} else if (typeof define === "function" && define.amd) {
define(function () {
return topbar;
});
} else {
this.topbar = topbar;
}
}.call(this, window, document));

65
config/config.exs Normal file
View file

@ -0,0 +1,65 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :my_first_elixir_vibe_code,
ecto_repos: [MyFirstElixirVibeCode.Repo],
generators: [timestamp_type: :utc_datetime]
# Configures the endpoint
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: MyFirstElixirVibeCodeWeb.ErrorHTML, json: MyFirstElixirVibeCodeWeb.ErrorJSON],
layout: false
],
pubsub_server: MyFirstElixirVibeCode.PubSub,
live_view: [signing_salt: "OuIDOGBt"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
my_first_elixir_vibe_code: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]
# Configure tailwind (the version is required)
config :tailwind,
version: "4.1.7",
my_first_elixir_vibe_code: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
# Configures Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

89
config/dev.exs Normal file
View file

@ -0,0 +1,89 @@
import Config
# Configure your database
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
username: "kevinsivic",
password: "",
hostname: "localhost",
database: "my_first_elixir_vibe_code_dev",
stacktrace: true,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}, port: String.to_integer(System.get_env("PORT") || "4000")],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "WVwNLGOP5SozZsdCz2RcJfF4+RMBAI8v67JpNO2iIUe6oTIQ8BSZVafUL8Z9hSUs",
watchers: [
esbuild:
{Esbuild, :install_and_run, [:my_first_elixir_vibe_code, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:my_first_elixir_vibe_code, ~w(--watch)]}
]
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Watch static and templates for browser reloading.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
~r"priv/gettext/.*(po)$",
~r"lib/my_first_elixir_vibe_code_web/(?:controllers|live|components|router)/?.*\.(ex|heex)$"
]
]
# Enable dev routes for dashboard and mailbox
config :my_first_elixir_vibe_code, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
config :phoenix_live_view,
# Include debug annotations and locations in rendered markup.
# Changing this configuration will require mix clean and a full recompile.
debug_heex_annotations: true,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false

21
config/prod.exs Normal file
View file

@ -0,0 +1,21 @@
import Config
# Note we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the `mix assets.deploy` task,
# which you should run after static files are built and
# before starting your production server.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

120
config/runtime.exs Normal file
View file

@ -0,0 +1,120 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/my_first_elixir_vibe_code start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint, server: true
end
if config_env() == :prod do
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
# ssl: true,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
# For machines with several cores, consider starting multiple pools of `pool_size`
# pool_count: 4,
socket_options: maybe_ipv6
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
port = String.to_integer(System.get_env("PORT") || "4000")
config :my_first_elixir_vibe_code, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
url: [host: host, port: port, scheme: "http"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base,
server: true
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end

37
config/test.exs Normal file
View file

@ -0,0 +1,37 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "my_first_elixir_vibe_code_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :my_first_elixir_vibe_code, MyFirstElixirVibeCodeWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "lytCVOsnu1RJs4fVgxkwUdnBrMZZT9HN9Me8jY1+DEdwj3MS54TZq+nHIna7f0iM",
server: false
# In test we don't send emails
config :my_first_elixir_vibe_code, MyFirstElixirVibeCode.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Enable helpful, but potentially expensive runtime checks
config :phoenix_live_view,
enable_expensive_runtime_checks: true

36
docker-compose.yml Normal file
View file

@ -0,0 +1,36 @@
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: my_first_elixir_vibe_code_dev
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
web:
build: .
ports:
- "4000:4000"
environment:
DATABASE_URL: "ecto://postgres:postgres@db/my_first_elixir_vibe_code_dev"
SECRET_KEY_BASE: "PNosxXTGA8DhjwcE5QWJkJZr400AFXjIWu27dEFQg/QJE3RyCna6HePec1Sjgz6W"
PHX_HOST: "localhost"
PORT: "4000"
depends_on:
db:
condition: service_healthy
volumes:
- ./priv/static:/app/priv/static
volumes:
postgres_data:

21
entrypoint.sh Normal file
View file

@ -0,0 +1,21 @@
#!/bin/sh
set -e
# Wait for Postgres to be ready
echo "Waiting for postgres..."
while ! nc -z db 5432; do
sleep 1
done
echo "PostgreSQL started"
# Create database if it doesn't exist
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.create_db()"
# Run migrations
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.migrate()"
# Create admin user if needed
bin/my_first_elixir_vibe_code eval "MyFirstElixirVibeCode.Release.seed_admin()"
# Start the Phoenix server
exec "$@"

View file

@ -0,0 +1,9 @@
defmodule MyFirstElixirVibeCode do
@moduledoc """
MyFirstElixirVibeCode keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View file

@ -0,0 +1,337 @@
defmodule MyFirstElixirVibeCode.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias MyFirstElixirVibeCode.Repo
alias MyFirstElixirVibeCode.Accounts.Client
@doc """
Returns the list of clients.
## Examples
iex> list_clients()
[%Client{}, ...]
"""
def list_clients do
Repo.all(Client)
end
@doc """
Gets a single client.
Raises `Ecto.NoResultsError` if the Client does not exist.
## Examples
iex> get_client!(123)
%Client{}
iex> get_client!(456)
** (Ecto.NoResultsError)
"""
def get_client!(id), do: Repo.get!(Client, id)
@doc """
Creates a client.
## Examples
iex> create_client(%{field: value})
{:ok, %Client{}}
iex> create_client(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_client(attrs) do
%Client{}
|> Client.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a client.
## Examples
iex> update_client(client, %{field: new_value})
{:ok, %Client{}}
iex> update_client(client, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_client(%Client{} = client, attrs) do
client
|> Client.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a client.
## Examples
iex> delete_client(client)
{:ok, %Client{}}
iex> delete_client(client)
{:error, %Ecto.Changeset{}}
"""
def delete_client(%Client{} = client) do
Repo.delete(client)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking client changes.
## Examples
iex> change_client(client)
%Ecto.Changeset{data: %Client{}}
"""
def change_client(%Client{} = client, attrs \\ %{}) do
Client.changeset(client, attrs)
end
alias MyFirstElixirVibeCode.Accounts.Contractor
@doc """
Returns the list of contractors.
## Examples
iex> list_contractors()
[%Contractor{}, ...]
"""
def list_contractors do
Repo.all(Contractor)
end
@doc """
Gets a single contractor.
Raises `Ecto.NoResultsError` if the Contractor does not exist.
## Examples
iex> get_contractor!(123)
%Contractor{}
iex> get_contractor!(456)
** (Ecto.NoResultsError)
"""
def get_contractor!(id), do: Repo.get!(Contractor, id)
@doc """
Creates a contractor.
## Examples
iex> create_contractor(%{field: value})
{:ok, %Contractor{}}
iex> create_contractor(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_contractor(attrs) do
%Contractor{}
|> Contractor.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a contractor.
## Examples
iex> update_contractor(contractor, %{field: new_value})
{:ok, %Contractor{}}
iex> update_contractor(contractor, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_contractor(%Contractor{} = contractor, attrs) do
contractor
|> Contractor.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a contractor.
## Examples
iex> delete_contractor(contractor)
{:ok, %Contractor{}}
iex> delete_contractor(contractor)
{:error, %Ecto.Changeset{}}
"""
def delete_contractor(%Contractor{} = contractor) do
Repo.delete(contractor)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking contractor changes.
## Examples
iex> change_contractor(contractor)
%Ecto.Changeset{data: %Contractor{}}
"""
def change_contractor(%Contractor{} = contractor, attrs \\ %{}) do
Contractor.changeset(contractor, attrs)
end
@doc """
Registers a new contractor with required documents.
## Examples
iex> register_contractor(%{field: value})
{:ok, %Contractor{}}
iex> register_contractor(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def register_contractor(attrs) do
%Contractor{}
|> Contractor.registration_changeset(attrs)
|> Repo.insert()
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking contractor registration changes.
## Examples
iex> change_contractor_registration(contractor)
%Ecto.Changeset{data: %Contractor{}}
"""
def change_contractor_registration(%Contractor{} = contractor, attrs \\ %{}) do
Contractor.registration_changeset(contractor, attrs)
end
alias MyFirstElixirVibeCode.Accounts.User
@doc """
Returns the list of users.
"""
def list_users do
Repo.all(User)
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
"""
def get_user!(id), do: Repo.get!(User, id)
@doc """
Gets a user by email.
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a user by username and password (for admin login).
"""
def get_user_by_username_and_password(username, password)
when is_binary(username) and is_binary(password) do
user = Repo.get_by(User, username: username)
if user && user.is_admin && User.valid_password?(user, password), do: user
end
@doc """
Creates a user.
## Examples
iex> create_user(%{field: value})
{:ok, %User{}}
iex> create_user(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_user(attrs \\ %{}) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a user.
## Examples
iex> update_user(user, %{field: new_value})
{:ok, %User{}}
iex> update_user(user, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_user(%User{} = user, attrs) do
user
|> User.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a user.
## Examples
iex> delete_user(user)
{:ok, %User{}}
iex> delete_user(user)
{:error, %Ecto.Changeset{}}
"""
def delete_user(%User{} = user) do
Repo.delete(user)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking user changes.
## Examples
iex> change_user(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user(%User{} = user, attrs \\ %{}) do
User.changeset(user, attrs)
end
end

View file

@ -0,0 +1,19 @@
defmodule MyFirstElixirVibeCode.Accounts.Client do
use Ecto.Schema
import Ecto.Changeset
schema "clients" do
field :name, :string
field :email, :string
field :company_name, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(client, attrs) do
client
|> cast(attrs, [:name, :email, :company_name])
|> validate_required([:name, :email, :company_name])
end
end

View file

@ -0,0 +1,33 @@
defmodule MyFirstElixirVibeCode.Accounts.Contractor do
use Ecto.Schema
import Ecto.Changeset
schema "contractors" do
field :name, :string
field :email, :string
field :company_name, :string
field :municipal_registration_proof, :string
field :insurance_proof, :string
field :registration_status, :string, default: "pending"
field :verified_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@doc false
def changeset(contractor, attrs) do
contractor
|> cast(attrs, [:name, :email, :company_name])
|> validate_required([:name, :email, :company_name])
end
@doc false
def registration_changeset(contractor, attrs) do
contractor
|> cast(attrs, [:name, :email, :company_name, :municipal_registration_proof, :insurance_proof])
|> validate_required([:name, :email, :company_name, :municipal_registration_proof, :insurance_proof])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must be a valid email")
|> unique_constraint(:email)
|> put_change(:registration_status, "pending")
end
end

View file

@ -0,0 +1,84 @@
defmodule MyFirstElixirVibeCode.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :username, :string
field :password, :string, virtual: true, redact: true
field :password_hash, :string
field :is_admin, :boolean, default: false
timestamps(type: :utc_datetime)
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :username, :password, :is_admin])
|> validate_required([:password])
|> validate_email_or_username()
|> validate_password()
|> put_password_hash()
end
defp validate_email_or_username(changeset) do
is_admin = get_field(changeset, :is_admin)
changeset
|> then(fn cs ->
if is_admin do
cs
|> validate_required([:username])
|> validate_length(:username, min: 3, max: 20)
|> unsafe_validate_unique(:username, MyFirstElixirVibeCode.Repo)
|> unique_constraint(:username)
else
cs
|> validate_required([:email])
|> validate_email()
end
end)
end
defp validate_email(changeset) do
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^\s]+@[^\s]+$/, message: "must have the @ sign and no spaces")
|> validate_length(:email, max: 160)
|> unsafe_validate_unique(:email, MyFirstElixirVibeCode.Repo)
|> unique_constraint(:email)
end
defp validate_password(changeset) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 6, max: 72)
end
defp put_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
change(changeset, password_hash: Bcrypt.hash_pwd_salt(password))
_ ->
changeset
end
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Bcrypt.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%__MODULE__{password_hash: password_hash}, password)
when is_binary(password_hash) and byte_size(password) > 0 do
Bcrypt.verify_pass(password, password_hash)
end
def valid_password?(_, _) do
Bcrypt.no_user_verify()
false
end
end

View file

@ -0,0 +1,34 @@
defmodule MyFirstElixirVibeCode.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
MyFirstElixirVibeCodeWeb.Telemetry,
MyFirstElixirVibeCode.Repo,
{DNSCluster, query: Application.get_env(:my_first_elixir_vibe_code, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: MyFirstElixirVibeCode.PubSub},
# Start a worker by calling: MyFirstElixirVibeCode.Worker.start_link(arg)
# {MyFirstElixirVibeCode.Worker, arg},
# Start to serve requests, typically the last entry
MyFirstElixirVibeCodeWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: MyFirstElixirVibeCode.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
MyFirstElixirVibeCodeWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View file

@ -0,0 +1,3 @@
defmodule MyFirstElixirVibeCode.Mailer do
use Swoosh.Mailer, otp_app: :my_first_elixir_vibe_code
end

View file

@ -0,0 +1,64 @@
defmodule MyFirstElixirVibeCode.Release do
@moduledoc """
Used for executing DB release tasks when run in production without Mix
installed.
"""
@app :my_first_elixir_vibe_code
def create_db do
load_app()
for repo <- repos() do
case repo.__adapter__.storage_up(repo.config) do
:ok -> IO.puts("Database created for #{inspect(repo)}")
{:error, :already_up} -> IO.puts("Database already exists for #{inspect(repo)}")
{:error, term} -> IO.puts("Error creating database: #{inspect(term)}")
end
end
end
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
def seed_admin do
load_app()
for repo <- repos() do
{:ok, _, _} =
Ecto.Migrator.with_repo(repo, fn _repo ->
# Check if admin user already exists
case MyFirstElixirVibeCode.Repo.get_by(MyFirstElixirVibeCode.Accounts.User, username: "admin") do
nil ->
# Create admin user
MyFirstElixirVibeCode.Accounts.create_user(%{
username: "admin",
password: "admin123",
is_admin: true
})
IO.puts("Admin user created (username: admin, password: admin123)")
_user ->
IO.puts("Admin user already exists")
end
end)
end
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
end

View file

@ -0,0 +1,5 @@
defmodule MyFirstElixirVibeCode.Repo do
use Ecto.Repo,
otp_app: :my_first_elixir_vibe_code,
adapter: Ecto.Adapters.Postgres
end

View file

@ -0,0 +1,133 @@
defmodule MyFirstElixirVibeCode.Reviews do
@moduledoc """
The Reviews context.
"""
import Ecto.Query, warn: false
alias MyFirstElixirVibeCode.Repo
alias MyFirstElixirVibeCode.Reviews.Review
@doc """
Returns the list of reviews.
## Examples
iex> list_reviews()
[%Review{}, ...]
"""
def list_reviews do
Repo.all(Review)
end
@doc """
Searches reviews by client name or address.
## Examples
iex> search_reviews("john")
[%Review{}, ...]
iex> search_reviews("")
[%Review{}, ...]
"""
def search_reviews(""), do: list_reviews()
def search_reviews(query) when is_binary(query) do
search_term = "%#{query}%"
from(r in Review,
where:
ilike(r.client_first_name, ^search_term) or
ilike(r.client_last_name, ^search_term) or
ilike(r.client_street_address, ^search_term) or
ilike(r.client_city, ^search_term) or
ilike(r.client_state, ^search_term) or
ilike(r.client_zip, ^search_term)
)
|> Repo.all()
end
@doc """
Gets a single review.
Raises `Ecto.NoResultsError` if the Review does not exist.
## Examples
iex> get_review!(123)
%Review{}
iex> get_review!(456)
** (Ecto.NoResultsError)
"""
def get_review!(id), do: Repo.get!(Review, id)
@doc """
Creates a review.
## Examples
iex> create_review(%{field: value})
{:ok, %Review{}}
iex> create_review(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_review(attrs) do
%Review{}
|> Review.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a review.
## Examples
iex> update_review(review, %{field: new_value})
{:ok, %Review{}}
iex> update_review(review, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_review(%Review{} = review, attrs) do
review
|> Review.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a review.
## Examples
iex> delete_review(review)
{:ok, %Review{}}
iex> delete_review(review)
{:error, %Ecto.Changeset{}}
"""
def delete_review(%Review{} = review) do
Repo.delete(review)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking review changes.
## Examples
iex> change_review(review)
%Ecto.Changeset{data: %Review{}}
"""
def change_review(%Review{} = review, attrs \\ %{}) do
Review.changeset(review, attrs)
end
end

View file

@ -0,0 +1,73 @@
defmodule MyFirstElixirVibeCode.Reviews.Review do
use Ecto.Schema
import Ecto.Changeset
schema "reviews" do
field :rating, :integer
field :title, :string
field :content, :string
field :project_type, :string
field :client_first_name, :string
field :client_last_name, :string
field :client_street_address, :string
field :client_city, :string
field :client_state, :string
field :client_zip, :string
field :client_country, :string
field :municipal_registration_proof, :string
field :work_permit_proof, :string
field :contractor_id, :id
field :client_id, :id
timestamps(type: :utc_datetime)
end
@doc false
def changeset(review, attrs) do
review
|> cast(attrs, [
:rating,
:title,
:content,
:project_type,
:client_first_name,
:client_last_name,
:client_street_address,
:client_city,
:client_state,
:client_zip,
:client_country,
:municipal_registration_proof,
:work_permit_proof
])
|> validate_required([
:rating,
:title,
:content,
:project_type,
:client_first_name,
:client_last_name,
:client_street_address,
:client_city,
:client_state,
:client_zip
])
|> validate_number(:rating, greater_than_or_equal_to: 1, less_than_or_equal_to: 5)
|> validate_at_least_one_document()
end
defp validate_at_least_one_document(changeset) do
municipal_proof = get_field(changeset, :municipal_registration_proof)
work_permit = get_field(changeset, :work_permit_proof)
if is_nil(municipal_proof) && is_nil(work_permit) do
add_error(
changeset,
:municipal_registration_proof,
"You must provide either municipal registration or work permit proof"
)
else
changeset
end
end
end

View file

@ -0,0 +1,114 @@
defmodule MyFirstElixirVibeCodeWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use MyFirstElixirVibeCodeWeb, :controller
use MyFirstElixirVibeCodeWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
import Phoenix.LiveView.Router
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
import Plug.Conn
unquote(verified_routes())
end
end
def live_view do
quote do
use Phoenix.LiveView
unquote(html_helpers())
end
end
def live_component do
quote do
use Phoenix.LiveComponent
unquote(html_helpers())
end
end
def html do
quote do
use Phoenix.Component
# Import convenience functions from controllers
import Phoenix.Controller,
only: [get_csrf_token: 0, view_module: 1, view_template: 1]
# Include general helpers for rendering HTML
unquote(html_helpers())
end
end
defp html_helpers do
quote do
# Translation
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import MyFirstElixirVibeCodeWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias MyFirstElixirVibeCodeWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: MyFirstElixirVibeCodeWeb.Endpoint,
router: MyFirstElixirVibeCodeWeb.Router,
statics: MyFirstElixirVibeCodeWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View file

@ -0,0 +1,472 @@
defmodule MyFirstElixirVibeCodeWeb.CoreComponents do
@moduledoc """
Provides core UI components.
At first glance, this module may seem daunting, but its goal is to provide
core building blocks for your application, such as tables, forms, and
inputs. The components consist mostly of markup and are well-documented
with doc strings and declarative assigns. You may customize and style
them in any way you want, based on your application growth and needs.
The foundation for styling is Tailwind CSS, a utility-first CSS framework,
augmented with daisyUI, a Tailwind CSS plugin that provides UI components
and themes. Here are useful references:
* [daisyUI](https://daisyui.com/docs/intro/) - a good place to get
started and see the available components.
* [Tailwind CSS](https://tailwindcss.com) - the foundational framework
we build on. You will use it for layout, sizing, flexbox, grid, and
spacing.
* [Heroicons](https://heroicons.com) - see `icon/1` for usage.
* [Phoenix.Component](https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html) -
the component system used by Phoenix. Some components, such as `<.link>`
and `<.form>`, are defined there.
"""
use Phoenix.Component
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
alias Phoenix.LiveView.JS
@doc """
Renders flash notices.
## Examples
<.flash kind={:info} flash={@flash} />
<.flash kind={:info} phx-mounted={show("#flash")}>Welcome Back!</.flash>
"""
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
def flash(assigns) do
assigns = assign_new(assigns, :id, fn -> "flash-#{assigns.kind}" end)
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
</div>
<div class="flex-1" />
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
</button>
</div>
</div>
"""
end
@doc """
Renders a button with navigation support.
## Examples
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :string
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
variants = %{"primary" => "btn-primary", nil => "btn-primary btn-soft"}
assigns =
assign_new(assigns, :class, fn ->
["btn", Map.fetch!(variants, assigns[:variant])]
end)
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
<.link class={@class} {@rest}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={@class} {@rest}>
{render_slot(@inner_block)}
</button>
"""
end
end
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Types
This function accepts all HTML input types, considering that:
* You may also set `type="select"` to render a `<select>` tag
* `type="checkbox"` is used exclusively to render boolean values
* For live file uploads, see `Phoenix.Component.live_file_input/1`
See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input
for more information. Unsupported types, such as hidden and radio,
are best written directly in your templates.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr :id, :any, default: nil
attr :name, :any
attr :label, :string, default: nil
attr :value, :any
attr :type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file month number password
search select tel text textarea time url week)
attr :field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
attr :errors, :list, default: []
attr :checked, :boolean, doc: "the checked flag for checkbox inputs"
attr :prompt, :string, default: nil, doc: "the prompt for select inputs"
attr :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
attr :multiple, :boolean, default: false, doc: "the multiple flag for select inputs"
attr :class, :string, default: nil, doc: "the input class to use over defaults"
attr :error_class, :string, default: nil, doc: "the input error class to use over defaults"
attr :rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
def input(%{type: "checkbox"} = assigns) do
assigns =
assign_new(assigns, :checked, fn ->
Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
end)
~H"""
<div class="fieldset mb-2">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
<input
type="checkbox"
id={@id}
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "select"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<select
id={@id}
name={@name}
class={[@class || "w-full select", @errors != [] && (@error_class || "select-error")]}
multiple={@multiple}
{@rest}
>
<option :if={@prompt} value="">{@prompt}</option>
{Phoenix.HTML.Form.options_for_select(@options, @value)}
</select>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
def input(%{type: "textarea"} = assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<textarea
id={@id}
name={@name}
class={[
@class || "w-full textarea",
@errors != [] && (@error_class || "textarea-error")
]}
{@rest}
>{Phoenix.HTML.Form.normalize_value("textarea", @value)}</textarea>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input",
@errors != [] && (@error_class || "input-error")
]}
{@rest}
/>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
# Helper used by inputs to generate form errors
defp error(assigns) do
~H"""
<p class="mt-1.5 flex gap-2 items-center text-sm text-error">
<.icon name="hero-exclamation-circle" class="size-5" />
{render_slot(@inner_block)}
</p>
"""
end
@doc """
Renders a header with title.
"""
slot :inner_block, required: true
slot :subtitle
slot :actions
def header(assigns) do
~H"""
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4"]}>
<div>
<h1 class="text-lg font-semibold leading-8">
{render_slot(@inner_block)}
</h1>
<p :if={@subtitle != []} class="text-sm text-base-content/70">
{render_slot(@subtitle)}
</p>
</div>
<div class="flex-none">{render_slot(@actions)}</div>
</header>
"""
end
@doc """
Renders a table with generic styling.
## Examples
<.table id="users" rows={@users}>
<:col :let={user} label="id">{user.id}</:col>
<:col :let={user} label="username">{user.username}</:col>
</.table>
"""
attr :id, :string, required: true
attr :rows, :list, required: true
attr :row_id, :any, default: nil, doc: "the function for generating the row id"
attr :row_click, :any, default: nil, doc: "the function for handling phx-click on each row"
attr :row_item, :any,
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
slot :col, required: true do
attr :label, :string
end
slot :action, doc: "the slot for showing user actions in the last table column"
def table(assigns) do
assigns =
with %{rows: %Phoenix.LiveView.LiveStream{}} <- assigns do
assign(assigns, row_id: assigns.row_id || fn {id, _item} -> id end)
end
~H"""
<table class="table table-zebra">
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
</tr>
</thead>
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
<td
:for={col <- @col}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{render_slot(col, @row_item.(row))}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>
{render_slot(action, @row_item.(row))}
<% end %>
</div>
</td>
</tr>
</tbody>
</table>
"""
end
@doc """
Renders a data list.
## Examples
<.list>
<:item title="Title">{@post.title}</:item>
<:item title="Views">{@post.views}</:item>
</.list>
"""
slot :item, required: true do
attr :title, :string, required: true
end
def list(assigns) do
~H"""
<ul class="list">
<li :for={item <- @item} class="list-row">
<div class="list-col-grow">
<div class="font-bold">{item.title}</div>
<div>{render_slot(item)}</div>
</div>
</li>
</ul>
"""
end
@doc """
Renders a [Heroicon](https://heroicons.com).
Heroicons come in three styles outline, solid, and mini.
By default, the outline style is used, but solid and mini may
be applied by using the `-solid` and `-mini` suffix.
You can customize the size and colors of the icons by setting
width, height, and background color classes.
Icons are extracted from the `deps/heroicons` directory and bundled within
your compiled app.css by the plugin in `assets/vendor/heroicons.js`.
## Examples
<.icon name="hero-x-mark" />
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
"""
attr :name, :string, required: true
attr :class, :string, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
## JS Commands
def show(js \\ %JS{}, selector) do
JS.show(js,
to: selector,
time: 300,
transition:
{"transition-all ease-out duration-300",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"}
)
end
def hide(js \\ %JS{}, selector) do
JS.hide(js,
to: selector,
time: 200,
transition:
{"transition-all ease-in duration-200", "opacity-100 translate-y-0 sm:scale-100",
"opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
)
end
@doc """
Translates an error message using gettext.
"""
def translate_error({msg, opts}) do
# When using gettext, we typically pass the strings we want
# to translate as a static argument:
#
# # Translate the number of files with plural rules
# dngettext("errors", "1 file", "%{count} files", count)
#
# However the error messages in our forms and APIs are generated
# dynamically, so we need to translate them by calling Gettext
# with our gettext backend as first argument. Translations are
# available in the errors.po file (as we use the "errors" domain).
if count = opts[:count] do
Gettext.dngettext(MyFirstElixirVibeCodeWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(MyFirstElixirVibeCodeWeb.Gettext, "errors", msg, opts)
end
end
@doc """
Translates the errors for a field from a keyword list of errors.
"""
def translate_errors(errors, field) when is_list(errors) do
for {^field, {msg, opts}} <- errors, do: translate_error({msg, opts})
end
end

View file

@ -0,0 +1,154 @@
defmodule MyFirstElixirVibeCodeWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use MyFirstElixirVibeCodeWeb, :html
# Embed all files in layouts/* within this module.
# The default root.html.heex file contains the HTML
# skeleton of your application, namely HTML headers
# and other static content.
embed_templates "layouts/*"
@doc """
Renders your app layout.
This function is typically invoked from every template,
and it often contains your application menu, sidebar,
or similar.
## Examples
<Layouts.app flash={@flash}>
<h1>Content</h1>
</Layouts.app>
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :current_scope, :map,
default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
slot :inner_block, required: true
def app(assigns) do
~H"""
<header class="navbar px-4 sm:px-6 lg:px-8">
<div class="flex-1">
<a href="/" class="flex-1 flex w-fit items-center gap-2">
<img src={~p"/images/logo.svg"} width="36" />
<span class="text-sm font-semibold">v{Application.spec(:phoenix, :vsn)}</span>
</a>
</div>
<div class="flex-none">
<ul class="flex flex-column px-1 space-x-4 items-center">
<li>
<a href="https://phoenixframework.org/" class="btn btn-ghost">Website</a>
</li>
<li>
<a href="https://github.com/phoenixframework/phoenix" class="btn btn-ghost">GitHub</a>
</li>
<li>
<.theme_toggle />
</li>
<li>
<a href="https://hexdocs.pm/phoenix/overview.html" class="btn btn-primary">
Get Started <span aria-hidden="true">&rarr;</span>
</a>
</li>
</ul>
</div>
</header>
<main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl space-y-4">
{render_slot(@inner_block)}
</div>
</main>
<.flash_group flash={@flash} />
"""
end
@doc """
Shows the flash group with standard titles and content.
## Examples
<.flash_group flash={@flash} />
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
attr :id, :string, default: "flash-group", doc: "the optional id of flash container"
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />
<.flash
id="client-error"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={show(".phx-client-error #client-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#client-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={show(".phx-server-error #server-error") |> JS.remove_attribute("hidden")}
phx-connected={hide("#server-error") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
"""
end
@doc """
Provides dark vs light theme toggle based on themes defined in app.css.
See <head> in root.html.heex which applies the theme before page load.
"""
def theme_toggle(assigns) do
~H"""
<div class="card relative flex flex-row items-center border-2 border-base-300 bg-base-300 rounded-full">
<div class="absolute w-1/3 h-full rounded-full border-1 border-base-200 bg-base-100 brightness-200 left-0 [[data-theme=light]_&]:left-1/3 [[data-theme=dark]_&]:left-2/3 transition-[left]" />
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="system"
>
<.icon name="hero-computer-desktop-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="light"
>
<.icon name="hero-sun-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
<button
class="flex p-2 cursor-pointer w-1/3"
phx-click={JS.dispatch("phx:set-theme")}
data-phx-theme="dark"
>
<.icon name="hero-moon-micro" class="size-4 opacity-75 hover:opacity-100" />
</button>
</div>
"""
end
end

View file

@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="RateMyClient™" suffix="">
{assigns[:page_title]}
</.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"}>
</script>
<script>
(() => {
const setTheme = (theme) => {
if (theme === "system") {
localStorage.removeItem("phx:theme");
document.documentElement.removeAttribute("data-theme");
} else {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
}
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", (e) => setTheme(e.target.dataset.phxTheme));
})();
</script>
</head>
<body>
<div class="navbar bg-base-300 shadow-lg">
<div class="navbar-start">
<div class="dropdown">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h8m-8 6h16"
/>
</svg>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content bg-base-100 rounded-box z-[1] mt-3 w-52 p-2 shadow"
>
<li><a href="/">Home</a></li>
<li><a href="/reviews/new">Write a Review</a></li>
<li><a href="/reviews">Find a Client</a></li>
<li><a href="/contact">Contact Us</a></li>
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">RateMyClient™</a>
<div class="hidden lg:flex">
<ul class="menu menu-horizontal px-1">
<li><a href="/">Home</a></li>
<li><a href="/reviews/new">Write a Review</a></li>
<li><a href="/reviews">Find a Client</a></li>
<li><a href="/contact">Contact Us</a></li>
</ul>
</div>
</div>
<div class="navbar-end">
<%= if assigns[:current_user] do %>
<div class="flex items-center gap-4">
<%= if @current_user.is_admin do %>
<a href="/admin/dashboard" class="btn btn-ghost">Admin Dashboard</a>
<% end %>
<span class="text-sm"><%= @current_user.email || @current_user.username %></span>
<form action="/logout" method="post">
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
<input type="hidden" name="_method" value="delete" />
<button type="submit" class="btn btn-error">Logout</button>
</form>
</div>
<% else %>
<div class="flex gap-2">
<a href="/login" class="btn btn-primary">Login</a>
<a href="/admin/login" class="btn btn-ghost">Admin</a>
</div>
<% end %>
</div>
</div>
{@inner_content}
</body>
</html>

View file

@ -0,0 +1,24 @@
defmodule MyFirstElixirVibeCodeWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use MyFirstElixirVibeCodeWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/my_first_elixir_vibe_code_web/controllers/error_html/404.html.heex
# * lib/my_first_elixir_vibe_code_web/controllers/error_html/500.html.heex
#
# embed_templates "error_html/*"
# The default is to render a plain text page based on
# the template name. For example, "404.html" becomes
# "Not Found".
def render(template, _assigns) do
Phoenix.Controller.status_message_from_template(template)
end
end

View file

@ -0,0 +1,21 @@
defmodule MyFirstElixirVibeCodeWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View file

@ -0,0 +1,7 @@
defmodule MyFirstElixirVibeCodeWeb.PageController do
use MyFirstElixirVibeCodeWeb, :controller
def home(conn, _params) do
render(conn, :home)
end
end

View file

@ -0,0 +1,10 @@
defmodule MyFirstElixirVibeCodeWeb.PageHTML do
@moduledoc """
This module contains pages rendered by PageController.
See the `page_html` directory for all templates available.
"""
use MyFirstElixirVibeCodeWeb, :html
embed_templates "page_html/*"
end

View file

@ -0,0 +1,112 @@
<Layouts.flash_group flash={@flash} />
<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-4xl">
<div class="text-center">
<h1 class="text-5xl font-bold tracking-tight sm:text-6xl mb-6">
RateMyClient
</h1>
<p class="text-2xl font-semibold text-primary mb-4">
Where Contractors Review Clients
</p>
<p class="text-xl leading-8 text-base-content/80 max-w-2xl mx-auto">
Building better business relationships, one honest review at a time.
</p>
</div>
<div class="mt-16 grid gap-8 md:grid-cols-2">
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
For Contractors
</h2>
<ul class="space-y-3 text-base-content/80">
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Screen potential clients</strong> before accepting projects</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Identify red flags early</strong> - late payments, scope creep, unrealistic expectations</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Save time and money</strong> by avoiding problematic clients</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Build your reputation</strong> by leaving professional, constructive reviews</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Connect with quality clients</strong> who value your work</span>
</li>
</ul>
</div>
</div>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
For Clients
</h2>
<ul class="space-y-3 text-base-content/80">
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Build a strong reputation</strong> as a reliable, professional client</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Attract top contractors</strong> who want to work with you</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Get better bids</strong> - contractors offer better rates to proven clients</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Gain valuable feedback</strong> on how to improve your project management</span>
</li>
<li class="flex gap-3">
<span class="text-primary">✓</span>
<span><strong>Stand out from the crowd</strong> with verified positive reviews</span>
</li>
</ul>
</div>
</div>
</div>
<div class="mt-16 card bg-primary text-primary-content shadow-xl">
<div class="card-body text-center">
<h2 class="card-title text-3xl justify-center mb-4">Why Two-Way Reviews Matter</h2>
<p class="text-lg leading-7 max-w-3xl mx-auto">
Traditional review sites only tell half the story. RateMyClient creates transparency and accountability
on both sides of the contractor-client relationship. When both parties know they'll be reviewed,
everyone brings their best to the table. The result? Better projects, stronger relationships,
and a healthier marketplace for everyone.
</p>
</div>
</div>
<div class="mt-12 text-center space-y-4">
<div>
<a href="/register" class="btn btn-primary btn-lg">
Register as Contractor
</a>
</div>
<div class="space-x-4">
<a href="/reviews/new" class="btn btn-outline btn-lg">
Submit a Review
</a>
<a href="/reviews" class="btn btn-outline btn-lg">
Browse Reviews
</a>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,36 @@
defmodule MyFirstElixirVibeCodeWeb.UserSessionController do
use MyFirstElixirVibeCodeWeb, :controller
alias MyFirstElixirVibeCode.Accounts
alias MyFirstElixirVibeCodeWeb.UserAuth
def create(conn, %{"email" => email, "password" => password}) do
case Accounts.get_user_by_email_and_password(email, password) do
nil ->
conn
|> put_flash(:error, "Invalid email or password")
|> redirect(to: ~p"/login")
user ->
UserAuth.log_in_user(conn, user)
end
end
def create_admin(conn, %{"username" => username, "password" => password}) do
case Accounts.get_user_by_username_and_password(username, password) do
nil ->
conn
|> put_flash(:error, "Invalid username or password")
|> redirect(to: ~p"/admin/login")
user ->
UserAuth.log_in_user(conn, user)
end
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end

View file

@ -0,0 +1,54 @@
defmodule MyFirstElixirVibeCodeWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :my_first_elixir_vibe_code
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_my_first_elixir_vibe_code_key",
signing_salt: "6MX1Nc4D",
same_site: "Lax"
]
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]],
longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :my_first_elixir_vibe_code,
gzip: not code_reloading?,
only: MyFirstElixirVibeCodeWeb.static_paths()
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
plug Phoenix.LiveReloader
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :my_first_elixir_vibe_code
end
plug Phoenix.LiveDashboard.RequestLogger,
param_key: "request_logger",
cookie_key: "request_logger"
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session, @session_options
plug MyFirstElixirVibeCodeWeb.Router
end

View file

@ -0,0 +1,25 @@
defmodule MyFirstElixirVibeCodeWeb.Gettext do
@moduledoc """
A module providing Internationalization with a gettext-based API.
By using [Gettext](https://hexdocs.pm/gettext), your module compiles translations
that you can use in your application. To use this Gettext backend module,
call `use Gettext` and pass it as an option:
use Gettext, backend: MyFirstElixirVibeCodeWeb.Gettext
# Simple translation
gettext("Here is the string to translate")
# Plural translation
ngettext("Here is the string to translate",
"Here are the strings to translate",
3)
# Domain-based translation
dgettext("errors", "Here is the error message to translate")
See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
"""
use Gettext.Backend, otp_app: :my_first_elixir_vibe_code
end

View file

@ -0,0 +1,182 @@
defmodule MyFirstElixirVibeCodeWeb.AdminDashboardLive do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Reviews
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-8">
<h1 class="text-4xl font-bold">Admin Dashboard</h1>
<button phx-click="logout" class="btn btn-error">Logout</button>
</div>
<div class="tabs tabs-boxed mb-6">
<a
class={["tab", @active_tab == "reviews" && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="reviews"
>
Reviews (<%= length(@reviews) %>)
</a>
<a
class={["tab", @active_tab == "stats" && "tab-active"]}
phx-click="switch_tab"
phx-value-tab="stats"
>
Statistics
</a>
</div>
<%= if @active_tab == "reviews" do %>
<div class="space-y-6">
<%= for review <- @reviews do %>
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h2 class="card-title text-primary mb-4">Review Details</h2>
<div class="space-y-2">
<p><strong>Rating:</strong> <%= review.rating %> / 5</p>
<p><strong>Title:</strong> <%= review.title %></p>
<p><strong>Content:</strong> <%= review.content %></p>
<p><strong>Project Type:</strong> <%= review.project_type %></p>
<p>
<strong>Submitted:</strong>
<%= Calendar.strftime(review.inserted_at, "%Y-%m-%d %H:%M") %>
</p>
</div>
</div>
<div>
<h3 class="text-xl font-bold text-secondary mb-4">
Client Information (Private)
</h3>
<div class="space-y-2 bg-base-300 p-4 rounded-lg">
<p>
<strong>Name:</strong>
<%= review.client_first_name %> <%= review.client_last_name %>
</p>
<p><strong>Street:</strong> <%= review.client_street_address %></p>
<p>
<strong>City:</strong>
<%= review.client_city %>, <%= review.client_state %> <%= review.client_zip %>
</p>
<%= if review.client_country do %>
<p><strong>Country:</strong> <%= review.client_country %></p>
<% end %>
</div>
<div class="mt-4">
<h4 class="font-bold mb-2">Verification Documents:</h4>
<%= if review.municipal_registration_proof do %>
<a
href={review.municipal_registration_proof}
target="_blank"
class="btn btn-sm btn-outline btn-primary"
>
View Document
</a>
<% end %>
</div>
</div>
</div>
</div>
</div>
<% end %>
</div>
<% end %>
<%= if @active_tab == "stats" do %>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="stat bg-base-200 rounded-lg shadow-xl">
<div class="stat-title">Total Reviews</div>
<div class="stat-value text-primary"><%= length(@reviews) %></div>
</div>
<div class="stat bg-base-200 rounded-lg shadow-xl">
<div class="stat-title">Average Rating</div>
<div class="stat-value text-secondary"><%= @avg_rating %></div>
</div>
<div class="stat bg-base-200 rounded-lg shadow-xl">
<div class="stat-title">Unique Clients</div>
<div class="stat-value text-accent"><%= @unique_clients %></div>
</div>
</div>
<div class="mt-8">
<h3 class="text-2xl font-bold mb-4">Recent Activity</h3>
<div class="overflow-x-auto">
<table class="table table-zebra w-full">
<thead>
<tr>
<th>Date</th>
<th>Client</th>
<th>Rating</th>
<th>Project Type</th>
</tr>
</thead>
<tbody>
<%= for review <- Enum.take(@reviews, 10) do %>
<tr>
<td><%= Calendar.strftime(review.inserted_at, "%Y-%m-%d") %></td>
<td>
<%= review.client_first_name %> <%= review.client_last_name %>
</td>
<td><%= review.rating %> / 5</td>
<td><%= review.project_type %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
reviews = Reviews.list_reviews()
avg_rating =
if Enum.empty?(reviews) do
0.0
else
(Enum.sum(Enum.map(reviews, & &1.rating)) / length(reviews))
|> Float.round(1)
end
unique_clients =
reviews
|> Enum.map(&{&1.client_first_name, &1.client_last_name})
|> Enum.uniq()
|> length()
{:ok,
socket
|> assign(:page_title, "Admin Dashboard")
|> assign(:reviews, reviews)
|> assign(:active_tab, "reviews")
|> assign(:avg_rating, avg_rating)
|> assign(:unique_clients, unique_clients)}
end
@impl true
def handle_event("switch_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :active_tab, tab)}
end
@impl true
def handle_event("logout", _params, socket) do
{:noreply,
socket
|> put_flash(:info, "Logged out successfully")
|> push_navigate(to: ~p"/login")}
end
end

View file

@ -0,0 +1,53 @@
defmodule MyFirstElixirVibeCodeWeb.AdminLoginLive do
use MyFirstElixirVibeCodeWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex items-center justify-center min-h-screen">
<div class="card w-96 bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-2xl mb-4">Admin Login</h2>
<form action={~p"/admin/login"} method="post" class="space-y-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<div class="form-control">
<label class="label">
<span class="label-text">Username</span>
</label>
<input
type="text"
name="username"
class="input input-bordered w-full"
required
autofocus
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input
type="password"
name="password"
class="input input-bordered w-full"
required
/>
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary w-full">Login</button>
</div>
</form>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end

View file

@ -0,0 +1,107 @@
defmodule MyFirstElixirVibeCodeWeb.ContactLive do
use MyFirstElixirVibeCodeWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex items-center justify-center min-h-screen p-4">
<div class="card w-full max-w-2xl bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-3xl mb-6">Contact Us</h2>
<p class="text-center text-sm opacity-70 mb-4">
Have a question or feedback? We'd love to hear from you!
</p>
<form phx-submit="submit" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Name</span>
</label>
<input
type="text"
name="name"
class="input input-bordered w-full"
placeholder="John Doe"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input
type="email"
name="email"
class="input input-bordered w-full"
placeholder="john@example.com"
required
/>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Subject</span>
</label>
<input
type="text"
name="subject"
class="input input-bordered w-full"
placeholder="What is this regarding?"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Message</span>
</label>
<textarea
name="message"
class="textarea textarea-bordered h-32"
placeholder="Your message here..."
required
></textarea>
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary w-full">
Send Message
</button>
</div>
</form>
<div class="divider">OR</div>
<div class="text-center">
<p class="text-sm opacity-70">
Email us directly at <a href="mailto:support@ratemyclient.com" class="link link-primary">support@ratemyclient.com</a>
</p>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_event("submit", params, socket) do
# This is a non-working contact form
# In a real app, you would send an email or save to database here
socket =
socket
|> put_flash(:info, "Thank you for your message! We'll get back to you soon. (Note: This is a demo form and does not actually send messages)")
|> push_navigate(to: ~p"/")
{:noreply, socket}
end
end

View file

@ -0,0 +1,275 @@
defmodule MyFirstElixirVibeCodeWeb.ContractorRegistrationLive do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Accounts
alias MyFirstElixirVibeCode.Accounts.Contractor
@impl true
def mount(_params, _session, socket) do
changeset = Accounts.change_contractor_registration(%Contractor{})
{:ok,
socket
|> assign(:changeset, changeset)
|> assign(:uploaded_files, [])
|> allow_upload(:municipal_registration, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)
|> allow_upload(:insurance_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)}
end
@impl true
def handle_event("validate", %{"contractor" => contractor_params}, socket) do
changeset =
%Contractor{}
|> Accounts.change_contractor_registration(contractor_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
@impl true
def handle_event("save", %{"contractor" => contractor_params}, socket) do
# Upload municipal registration
municipal_paths =
consume_uploaded_entries(socket, :municipal_registration, fn %{path: path}, entry ->
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
File.mkdir_p!(Path.dirname(dest))
File.cp!(path, dest)
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
end)
# Upload insurance proof
insurance_paths =
consume_uploaded_entries(socket, :insurance_proof, fn %{path: path}, entry ->
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
File.mkdir_p!(Path.dirname(dest))
File.cp!(path, dest)
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
end)
contractor_params =
contractor_params
|> Map.put("municipal_registration_proof", List.first(municipal_paths))
|> Map.put("insurance_proof", List.first(insurance_paths))
case Accounts.register_contractor(contractor_params) do
{:ok, _contractor} ->
{:noreply,
socket
|> put_flash(:info, "Registration submitted successfully! Your application is pending review.")
|> push_navigate(to: ~p"/")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp ext(entry) do
[ext | _] = MIME.extensions(entry.client_type)
ext
end
@impl true
def render(assigns) do
~H"""
<div class="mx-auto max-w-2xl px-4 py-10 sm:px-6 lg:px-8">
<div class="mb-8">
<h1 class="text-4xl font-bold">Contractor Registration</h1>
<p class="mt-2 text-lg text-base-content/70">
Register to start submitting client reviews
</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<.form
:let={f}
for={@changeset}
as={:contractor}
phx-change="validate"
phx-submit="save"
class="space-y-6"
>
<div>
<.input field={f[:name]} type="text" label="Full Name" required />
</div>
<div>
<.input field={f[:email]} type="email" label="Email Address" required />
</div>
<div>
<.input field={f[:company_name]} type="text" label="Company Name" required />
</div>
<div class="divider">Required Documents</div>
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
>
</path>
</svg>
<span>
You must provide proof of at least one municipal registration and current business insurance to complete registration.
</span>
</div>
<div>
<label class="label">
<span class="label-text font-semibold">
Municipal Registration Proof <span class="text-error">*</span>
</span>
</label>
<div class="form-control">
<label
for="municipal_registration"
class="btn btn-outline btn-block justify-start cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Choose File (PDF, JPG, PNG)
</label>
<input
id="municipal_registration"
type="file"
phx-hook="Phoenix.LiveFileUpload"
data-phx-upload-ref={@uploads.municipal_registration.ref}
data-phx-active-refs=""
data-phx-done-refs=""
data-phx-preflighted-refs=""
data-phx-update="ignore"
class="hidden"
/>
</div>
<%= for entry <- @uploads.municipal_registration.entries do %>
<div class="mt-2 flex items-center gap-2 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-success"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<span><%= entry.client_name %></span>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.municipal_registration) do %>
<p class="text-error text-sm mt-1"><%= error_to_string(err) %></p>
<% end %>
</div>
<div>
<label class="label">
<span class="label-text font-semibold">
Business Insurance Proof <span class="text-error">*</span>
</span>
</label>
<div class="form-control">
<label
for="insurance_proof"
class="btn btn-outline btn-block justify-start cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Choose File (PDF, JPG, PNG)
</label>
<input
id="insurance_proof"
type="file"
phx-hook="Phoenix.LiveFileUpload"
data-phx-upload-ref={@uploads.insurance_proof.ref}
data-phx-active-refs=""
data-phx-done-refs=""
data-phx-preflighted-refs=""
data-phx-update="ignore"
class="hidden"
/>
</div>
<%= for entry <- @uploads.insurance_proof.entries do %>
<div class="mt-2 flex items-center gap-2 text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-success"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
<span><%= entry.client_name %></span>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.insurance_proof) do %>
<p class="text-error text-sm mt-1"><%= error_to_string(err) %></p>
<% end %>
</div>
<div class="card-actions justify-end mt-6">
<.link navigate={~p"/"} class="btn btn-ghost">Cancel</.link>
<button
type="submit"
class="btn btn-primary"
disabled={
!@changeset.valid? || @uploads.municipal_registration.entries == [] ||
@uploads.insurance_proof.entries == []
}
>
Submit Registration
</button>
</div>
</.form>
</div>
</div>
</div>
"""
end
defp error_to_string(:too_large), do: "File is too large"
defp error_to_string(:not_accepted), do: "File type not accepted"
defp error_to_string(:too_many_files), do: "Too many files"
end

View file

@ -0,0 +1,258 @@
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Form do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Reviews
alias MyFirstElixirVibeCode.Reviews.Review
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
{@page_title}
<:subtitle>Use this form to manage review records in your database.</:subtitle>
</.header>
<.form for={@form} id="review-form" phx-change="validate" phx-submit="save">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Rating</span>
</label>
<div class="rating rating-lg">
<input
type="radio"
name="review[rating]"
value="1"
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 1}
class="mask mask-star-2 bg-orange-400"
/>
<input
type="radio"
name="review[rating]"
value="2"
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 2}
class="mask mask-star-2 bg-orange-400"
/>
<input
type="radio"
name="review[rating]"
value="3"
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 3}
class="mask mask-star-2 bg-orange-400"
/>
<input
type="radio"
name="review[rating]"
value="4"
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 4}
class="mask mask-star-2 bg-orange-400"
/>
<input
type="radio"
name="review[rating]"
value="5"
checked={Phoenix.HTML.Form.input_value(@form, :rating) == 5}
class="mask mask-star-2 bg-orange-400"
/>
</div>
</div>
<.input field={@form[:title]} type="text" label="Title" />
<.input field={@form[:content]} type="textarea" label="Content" />
<.input field={@form[:project_type]} type="text" label="Project type" />
<div class="divider mt-6">Client Information</div>
<div class="alert alert-warning mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>
This information is private and will not be shown publicly. It can only be searched by other contractors.
</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@form[:client_first_name]} type="text" label="First Name" required />
<.input field={@form[:client_last_name]} type="text" label="Last Name" required />
</div>
<.input
field={@form[:client_street_address]}
type="text"
label="Street Address"
required
/>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input field={@form[:client_city]} type="text" label="City" required />
<.input field={@form[:client_state]} type="text" label="State/Province" required />
<.input field={@form[:client_zip]} type="text" label="ZIP/Postal Code" required />
</div>
<.input field={@form[:client_country]} type="text" label="Country" />
<div class="divider mt-6">Required Verification</div>
<div class="alert alert-info mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
>
</path>
</svg>
<span>
You must provide proof that you are registered in the client's municipality OR that a permit was pulled for this work.
</span>
</div>
<div>
<label class="label">
<span class="label-text font-semibold">
Verification Document (Municipal Registration OR Work Permit)
</span>
</label>
<div class="form-control">
<input
id="verification_proof_upload"
type="file"
phx-hook="Phoenix.LiveFileUpload"
data-phx-upload-ref={@uploads.verification_proof.ref}
data-phx-active-refs=""
data-phx-done-refs=""
data-phx-preflighted-refs=""
data-phx-update="ignore"
accept=".pdf,.jpg,.jpeg,.png"
class="file-input file-input-bordered w-full"
/>
</div>
<%= for entry <- @uploads.verification_proof.entries do %>
<div class="mt-2 flex items-center gap-2 text-sm text-success">
<%= entry.client_name %>
</div>
<% end %>
</div>
<footer class="mt-6">
<.button phx-disable-with="Saving..." variant="primary">Save Review</.button>
<.button navigate={return_path(@return_to, @review)}>Cancel</.button>
</footer>
</.form>
</Layouts.app>
"""
end
@impl true
def mount(params, _session, socket) do
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> allow_upload(:verification_proof, accept: ~w(.pdf .jpg .jpeg .png), max_entries: 1)
|> apply_action(socket.assigns.live_action, params)}
end
defp return_to("show"), do: "show"
defp return_to(_), do: "index"
defp apply_action(socket, :edit, %{"id" => id}) do
review = Reviews.get_review!(id)
socket
|> assign(:page_title, "Edit Review")
|> assign(:review, review)
|> assign(:form, to_form(Reviews.change_review(review)))
end
defp apply_action(socket, :new, _params) do
review = %Review{}
socket
|> assign(:page_title, "New Review")
|> assign(:review, review)
|> assign(:form, to_form(Reviews.change_review(review)))
end
@impl true
def handle_event("validate", %{"review" => review_params}, socket) do
changeset = Reviews.change_review(socket.assigns.review, review_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
def handle_event("save", %{"review" => review_params}, socket) do
save_review(socket, socket.assigns.live_action, review_params)
end
defp save_review(socket, :edit, review_params) do
review_params = consume_uploads(socket, review_params)
case Reviews.update_review(socket.assigns.review, review_params) do
{:ok, review} ->
{:noreply,
socket
|> put_flash(:info, "Review updated successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, review))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp save_review(socket, :new, review_params) do
review_params = consume_uploads(socket, review_params)
case Reviews.create_review(review_params) do
{:ok, review} ->
{:noreply,
socket
|> put_flash(:info, "Review created successfully")
|> push_navigate(to: return_path(socket.assigns.return_to, review))}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
defp consume_uploads(socket, review_params) do
# Upload verification proof (either municipal registration or work permit)
verification_paths =
consume_uploaded_entries(socket, :verification_proof, fn %{path: path}, entry ->
dest = Path.join("priv/static/uploads", "#{entry.uuid}.#{ext(entry)}")
File.mkdir_p!(Path.dirname(dest))
File.cp!(path, dest)
{:ok, "/uploads/#{entry.uuid}.#{ext(entry)}"}
end)
# Store to municipal_registration_proof field
review_params
|> maybe_put_upload(:municipal_registration_proof, verification_paths)
end
defp maybe_put_upload(params, _key, []), do: params
defp maybe_put_upload(params, key, [path | _]), do: Map.put(params, to_string(key), path)
defp ext(entry) do
[ext | _] = MIME.extensions(entry.client_type)
ext
end
defp return_path("index", _review), do: ~p"/reviews"
defp return_path("show", review), do: ~p"/reviews/#{review}"
end

View file

@ -0,0 +1,100 @@
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Index do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Reviews
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Listing Reviews
<:actions>
<.button variant="primary" navigate={~p"/reviews/new"}>
<.icon name="hero-plus" /> New Review
</.button>
</:actions>
</.header>
<div class="mb-6">
<.form for={%{}} phx-change="search" phx-submit="search" class="flex gap-4">
<div class="flex-1">
<input
type="text"
name="search_query"
value={@search_query}
placeholder="Search by client name or address..."
class="input input-bordered w-full"
/>
</div>
<button type="button" phx-click="clear_search" class="btn btn-ghost">
Clear
</button>
</.form>
</div>
<.table
id="reviews"
rows={@streams.reviews}
row_click={fn {_id, review} -> JS.navigate(~p"/reviews/#{review}") end}
>
<:col :let={{_id, review}} label="Rating">{review.rating}</:col>
<:col :let={{_id, review}} label="Title">{review.title}</:col>
<:col :let={{_id, review}} label="Content">{review.content}</:col>
<:col :let={{_id, review}} label="Project type">{review.project_type}</:col>
<:action :let={{_id, review}}>
<div class="sr-only">
<.link navigate={~p"/reviews/#{review}"}>Show</.link>
</div>
<.link navigate={~p"/reviews/#{review}/edit"}>Edit</.link>
</:action>
<:action :let={{id, review}}>
<.link
phx-click={JS.push("delete", value: %{id: review.id}) |> hide("##{id}")}
data-confirm="Are you sure?"
>
Delete
</.link>
</:action>
</.table>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Reviews")
|> assign(:search_query, "")
|> stream(:reviews, list_reviews(""))}
end
@impl true
def handle_event("search", %{"search_query" => query}, socket) do
{:noreply,
socket
|> assign(:search_query, query)
|> stream(:reviews, list_reviews(query), reset: true)}
end
@impl true
def handle_event("clear_search", _params, socket) do
{:noreply,
socket
|> assign(:search_query, "")
|> stream(:reviews, list_reviews(""), reset: true)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
review = Reviews.get_review!(id)
{:ok, _} = Reviews.delete_review(review)
{:noreply, stream_delete(socket, :reviews, review)}
end
defp list_reviews(search_query) do
Reviews.search_reviews(search_query)
end
end

View file

@ -0,0 +1,40 @@
defmodule MyFirstElixirVibeCodeWeb.ReviewLive.Show do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Reviews
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<.header>
Review {@review.id}
<:subtitle>This is a review record from your database.</:subtitle>
<:actions>
<.button navigate={~p"/reviews"}>
<.icon name="hero-arrow-left" />
</.button>
<.button variant="primary" navigate={~p"/reviews/#{@review}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit review
</.button>
</:actions>
</.header>
<.list>
<:item title="Rating">{@review.rating}</:item>
<:item title="Title">{@review.title}</:item>
<:item title="Content">{@review.content}</:item>
<:item title="Project type">{@review.project_type}</:item>
</.list>
</Layouts.app>
"""
end
@impl true
def mount(%{"id" => id}, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Show Review")
|> assign(:review, Reviews.get_review!(id))}
end
end

View file

@ -0,0 +1,62 @@
defmodule MyFirstElixirVibeCodeWeb.UserLoginLive do
use MyFirstElixirVibeCodeWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex items-center justify-center min-h-screen">
<div class="card w-96 bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-2xl mb-4">Login</h2>
<form action={~p"/login"} method="post" class="space-y-4">
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<input
type="email"
name="email"
class="input input-bordered w-full"
required
autofocus
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<input
type="password"
name="password"
class="input input-bordered w-full"
required
/>
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary w-full">Login</button>
</div>
</form>
<div class="divider">OR</div>
<div class="text-center">
<p class="text-sm">
Don't have an account?
<a href="/register" class="link link-primary">Register</a>
</p>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
end

View file

@ -0,0 +1,105 @@
defmodule MyFirstElixirVibeCodeWeb.UserRegistrationLive do
use MyFirstElixirVibeCodeWeb, :live_view
alias MyFirstElixirVibeCode.Accounts
alias MyFirstElixirVibeCode.Accounts.User
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div class="flex items-center justify-center min-h-screen">
<div class="card w-96 bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title justify-center text-2xl mb-4">Register</h2>
<.form
for={@form}
id="registration_form"
phx-submit="save"
phx-change="validate"
class="space-y-4"
>
<div class="form-control">
<label class="label">
<span class="label-text">Email</span>
</label>
<.input field={@form[:email]} type="email" required />
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Password</span>
</label>
<.input field={@form[:password]} type="password" required />
<label class="label">
<span class="label-text-alt">Must be at least 6 characters</span>
</label>
</div>
<div class="card-actions justify-end">
<button type="submit" class="btn btn-primary w-full">
Register
</button>
</div>
</.form>
<div class="divider">OR</div>
<div class="text-center">
<p class="text-sm">
Already have an account?
<a href="/login" class="link link-primary">Log in</a>
</p>
</div>
</div>
</div>
</div>
</Layouts.app>
"""
end
@impl true
def mount(_params, _session, socket) do
changeset = Accounts.change_user(%User{})
socket =
socket
|> assign(trigger_submit: false)
|> assign_form(changeset)
{:ok, socket, temporary_assigns: [form: nil]}
end
@impl true
def handle_event("validate", %{"user" => user_params}, socket) do
changeset =
%User{}
|> Accounts.change_user(user_params)
|> Map.put(:action, :validate)
{:noreply, assign_form(socket, changeset)}
end
def handle_event("save", %{"user" => user_params}, socket) do
case Accounts.create_user(user_params) do
{:ok, user} ->
{:noreply,
socket
|> put_flash(:info, "User created successfully")
|> push_navigate(to: ~p"/login")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign_form(socket, Map.put(changeset, :action, :insert))}
end
end
defp assign_form(socket, %Ecto.Changeset{} = changeset) do
form = to_form(changeset, as: "user")
if changeset.valid? do
assign(socket, form: form, check_errors: false)
else
assign(socket, form: form)
end
end
end

View file

@ -0,0 +1,98 @@
defmodule MyFirstElixirVibeCodeWeb.Router do
use MyFirstElixirVibeCodeWeb, :router
alias MyFirstElixirVibeCodeWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyFirstElixirVibeCodeWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug UserAuth, :fetch_current_user
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :guest_only do
plug UserAuth, :redirect_if_user_is_authenticated
end
pipeline :auth_required do
plug UserAuth, :require_authenticated_user
end
pipeline :admin_only do
plug UserAuth, :require_admin_user
end
scope "/", MyFirstElixirVibeCodeWeb do
pipe_through :browser
get "/", PageController, :home
live "/contractor/register", ContractorRegistrationLive
live "/contact", ContactLive
live "/reviews", ReviewLive.Index, :index
live "/reviews/new", ReviewLive.Form, :new
live "/reviews/:id", ReviewLive.Show, :show
live "/reviews/:id/edit", ReviewLive.Form, :edit
end
## Authentication routes
scope "/", MyFirstElixirVibeCodeWeb do
pipe_through [:browser, :guest_only]
live_session :redirect_if_user_is_authenticated,
on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :redirect_if_user_is_authenticated}] do
live "/register", UserRegistrationLive, :new
live "/login", UserLoginLive, :new
live "/admin/login", AdminLoginLive, :new
end
post "/login", UserSessionController, :create
post "/admin/login", UserSessionController, :create_admin
end
scope "/", MyFirstElixirVibeCodeWeb do
pipe_through [:browser, :auth_required]
delete "/logout", UserSessionController, :delete
end
## Admin routes
scope "/admin", MyFirstElixirVibeCodeWeb do
pipe_through [:browser, :auth_required, :admin_only]
live_session :require_admin_user,
on_mount: [{MyFirstElixirVibeCodeWeb.UserAuth, :ensure_authenticated}] do
live "/dashboard", AdminDashboardLive
end
end
# Other scopes may use custom stacks.
# scope "/api", MyFirstElixirVibeCodeWeb do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:my_first_elixir_vibe_code, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through :browser
live_dashboard "/dashboard", metrics: MyFirstElixirVibeCodeWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
end

View file

@ -0,0 +1,93 @@
defmodule MyFirstElixirVibeCodeWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("my_first_elixir_vibe_code.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("my_first_elixir_vibe_code.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("my_first_elixir_vibe_code.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("my_first_elixir_vibe_code.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("my_first_elixir_vibe_code.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {MyFirstElixirVibeCodeWeb, :count_users, []}
]
end
end

View file

@ -0,0 +1,173 @@
defmodule MyFirstElixirVibeCodeWeb.UserAuth do
@moduledoc """
User authentication module for managing user sessions.
"""
use MyFirstElixirVibeCodeWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias MyFirstElixirVibeCode.Accounts
@max_age 60 * 60 * 24 * 60 # 60 days
@remember_me_cookie "_my_first_elixir_vibe_code_web_user_remember_me"
@remember_me_options [sign: true, max_age: @max_age, same_site: "Lax"]
# Plug behaviour
def init(opts), do: opts
def call(conn, opts) when is_atom(opts) do
apply(__MODULE__, opts, [conn, opts])
end
@doc """
Logs the user in.
It renews the session ID to avoid fixation attacks.
"""
def log_in_user(conn, user) do
token = :crypto.strong_rand_bytes(32) |> Base.url_encode64(padding: false)
user_return_to = get_session(conn, :user_return_to)
conn
|> renew_session()
|> put_token_in_session(token)
|> put_session(:user_id, user.id)
|> put_session(:is_admin, user.is_admin)
|> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}")
|> redirect(to: user_return_to || signed_in_path(user))
end
defp signed_in_path(user) do
if user.is_admin do
~p"/admin/dashboard"
else
~p"/"
end
end
defp renew_session(conn) do
conn
|> configure_session(renew: true)
|> clear_session()
end
@doc """
Logs the user out.
It clears all session data for safety.
"""
def log_out_user(conn) do
if live_socket_id = get_session(conn, :live_socket_id) do
MyFirstElixirVibeCodeWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session()
|> delete_resp_cookie(@remember_me_cookie)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session.
"""
def fetch_current_user(conn, _opts) do
user_id = get_session(conn, :user_id)
user = user_id && Accounts.get_user!(user_id)
assign(conn, :current_user, user)
end
@doc """
Handles mounting and authenticating the current_user in LiveViews.
"""
def on_mount(:mount_current_user, _params, session, socket) do
{:cont, mount_current_user(socket, session)}
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/login")
{:halt, socket}
end
end
def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do
socket = mount_current_user(socket, session)
if socket.assigns.current_user do
{:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket.assigns.current_user))}
else
{:cont, socket}
end
end
defp mount_current_user(socket, session) do
Phoenix.Component.assign_new(socket, :current_user, fn ->
if user_id = session["user_id"] do
Accounts.get_user!(user_id)
end
end)
end
@doc """
Used for routes that require the user to not be authenticated.
"""
def redirect_if_user_is_authenticated(conn, _opts) do
if conn.assigns[:current_user] do
conn
|> redirect(to: signed_in_path(conn.assigns.current_user))
|> halt()
else
conn
end
end
@doc """
Used for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_user] do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/login")
|> halt()
end
end
@doc """
Used for routes that require the user to be an admin.
"""
def require_admin_user(conn, _opts) do
if conn.assigns[:current_user] && conn.assigns.current_user.is_admin do
conn
else
conn
|> put_flash(:error, "You must be an admin to access this page.")
|> redirect(to: ~p"/")
|> halt()
end
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
end

95
mix.exs Normal file
View file

@ -0,0 +1,95 @@
defmodule MyFirstElixirVibeCode.MixProject do
use Mix.Project
def project do
[
app: :my_first_elixir_vibe_code,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
compilers: [:phoenix_live_view] ++ Mix.compilers(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {MyFirstElixirVibeCode.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:lazy_html, ">= 0.1.0", only: :test},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"},
{:bcrypt_elixir, "~> 3.0"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind my_first_elixir_vibe_code", "esbuild my_first_elixir_vibe_code"],
"assets.deploy": [
"tailwind my_first_elixir_vibe_code --minify",
"esbuild my_first_elixir_vibe_code --minify",
"phx.digest"
],
precommit: ["compile --warning-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

48
mix.lock Normal file
View file

@ -0,0 +1,48 @@
%{
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [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", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [: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", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [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", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [: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", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [: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", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"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.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {: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", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View file

@ -0,0 +1,112 @@
## `msgid`s in this file come from POT (.pot) files.
##
## Do not add, change, or remove `msgid`s manually here as
## they're tied to the ones in the corresponding POT file
## (with the same domain).
##
## Use `mix gettext.extract --merge` or `mix gettext.merge`
## to merge POT files into PO files.
msgid ""
msgstr ""
"Language: en\n"
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

109
priv/gettext/errors.pot Normal file
View file

@ -0,0 +1,109 @@
## This is a PO Template file.
##
## `msgid`s here are often extracted from source code.
## Add new translations manually only if they're dynamic
## translations that can't be statically extracted.
##
## Run `mix gettext.extract` to bring this file up to
## date. Leave `msgstr`s empty as changing them here has no
## effect: edit them in PO (`.po`) files instead.
## From Ecto.Changeset.cast/4
msgid "can't be blank"
msgstr ""
## From Ecto.Changeset.unique_constraint/3
msgid "has already been taken"
msgstr ""
## From Ecto.Changeset.put_change/3
msgid "is invalid"
msgstr ""
## From Ecto.Changeset.validate_acceptance/3
msgid "must be accepted"
msgstr ""
## From Ecto.Changeset.validate_format/3
msgid "has invalid format"
msgstr ""
## From Ecto.Changeset.validate_subset/3
msgid "has an invalid entry"
msgstr ""
## From Ecto.Changeset.validate_exclusion/3
msgid "is reserved"
msgstr ""
## From Ecto.Changeset.validate_confirmation/3
msgid "does not match confirmation"
msgstr ""
## From Ecto.Changeset.no_assoc_constraint/3
msgid "is still associated with this entry"
msgstr ""
msgid "are still associated with this entry"
msgstr ""
## From Ecto.Changeset.validate_length/3
msgid "should have %{count} item(s)"
msgid_plural "should have %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} character(s)"
msgid_plural "should be %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be %{count} byte(s)"
msgid_plural "should be %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at least %{count} item(s)"
msgid_plural "should have at least %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} character(s)"
msgid_plural "should be at least %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at least %{count} byte(s)"
msgid_plural "should be at least %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should have at most %{count} item(s)"
msgid_plural "should have at most %{count} item(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} character(s)"
msgid_plural "should be at most %{count} character(s)"
msgstr[0] ""
msgstr[1] ""
msgid "should be at most %{count} byte(s)"
msgid_plural "should be at most %{count} byte(s)"
msgstr[0] ""
msgstr[1] ""
## From Ecto.Changeset.validate_number/3
msgid "must be less than %{number}"
msgstr ""
msgid "must be greater than %{number}"
msgstr ""
msgid "must be less than or equal to %{number}"
msgstr ""
msgid "must be greater than or equal to %{number}"
msgstr ""
msgid "must be equal to %{number}"
msgstr ""

View file

@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View file

@ -0,0 +1,13 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateClients do
use Ecto.Migration
def change do
create table(:clients) do
add :name, :string
add :email, :string
add :company_name, :string
timestamps(type: :utc_datetime)
end
end
end

View file

@ -0,0 +1,13 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateContractors do
use Ecto.Migration
def change do
create table(:contractors) do
add :name, :string
add :email, :string
add :company_name, :string
timestamps(type: :utc_datetime)
end
end
end

View file

@ -0,0 +1,19 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateReviews do
use Ecto.Migration
def change do
create table(:reviews) do
add :rating, :integer
add :title, :string
add :content, :text
add :project_type, :string
add :contractor_id, references(:contractors, on_delete: :nothing)
add :client_id, references(:clients, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:reviews, [:contractor_id])
create index(:reviews, [:client_id])
end
end

View file

@ -0,0 +1,12 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToContractors do
use Ecto.Migration
def change do
alter table(:contractors) do
add :municipal_registration_proof, :string
add :insurance_proof, :string
add :registration_status, :string, default: "pending"
add :verified_at, :utc_datetime
end
end
end

View file

@ -0,0 +1,7 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUniqueIndexToContractorEmail do
use Ecto.Migration
def change do
create unique_index(:contractors, [:email])
end
end

View file

@ -0,0 +1,10 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddDocumentsToReviews do
use Ecto.Migration
def change do
alter table(:reviews) do
add :municipal_registration_proof, :string
add :work_permit_proof, :string
end
end
end

View file

@ -0,0 +1,13 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddClientInfoToReviews do
use Ecto.Migration
def change do
alter table(:reviews) do
add :client_name, :string
add :client_address, :string
end
create index(:reviews, [:client_name])
create index(:reviews, [:client_address])
end
end

View file

@ -0,0 +1,28 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.UpdateClientFieldsInReviews do
use Ecto.Migration
def change do
alter table(:reviews) do
# Remove old fields
remove :client_name
remove :client_address
# Add split name fields
add :client_first_name, :string
add :client_last_name, :string
# Add address components
add :client_street_address, :string
add :client_city, :string
add :client_state, :string
add :client_zip, :string
add :client_country, :string
end
# Create indexes for searching
create index(:reviews, [:client_first_name])
create index(:reviews, [:client_last_name])
create index(:reviews, [:client_street_address])
create index(:reviews, [:client_city])
end
end

View file

@ -0,0 +1,15 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string, null: false
add :password_hash, :string, null: false
add :is_admin, :boolean, default: false, null: false
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
end
end

View file

@ -0,0 +1,11 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.AddUsernameToUsers do
use Ecto.Migration
def change do
alter table(:users) do
add :username, :string
end
create unique_index(:users, [:username])
end
end

View file

@ -0,0 +1,9 @@
defmodule MyFirstElixirVibeCode.Repo.Migrations.MakeEmailNullable do
use Ecto.Migration
def change do
alter table(:users) do
modify :email, :string, null: true
end
end
end

11
priv/repo/seeds.exs Normal file
View file

@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# MyFirstElixirVibeCode.Repo.insert!(%MyFirstElixirVibeCode.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

BIN
priv/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" 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-.077.057c1.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.728a13 13 0 0 0 1.182 1.106c1.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.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3 KiB

5
priv/static/robots.txt Normal file
View file

@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View file

@ -0,0 +1,121 @@
defmodule MyFirstElixirVibeCode.AccountsTest do
use MyFirstElixirVibeCode.DataCase
alias MyFirstElixirVibeCode.Accounts
describe "clients" do
alias MyFirstElixirVibeCode.Accounts.Client
import MyFirstElixirVibeCode.AccountsFixtures
@invalid_attrs %{name: nil, email: nil, company_name: nil}
test "list_clients/0 returns all clients" do
client = client_fixture()
assert Accounts.list_clients() == [client]
end
test "get_client!/1 returns the client with given id" do
client = client_fixture()
assert Accounts.get_client!(client.id) == client
end
test "create_client/1 with valid data creates a client" do
valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"}
assert {:ok, %Client{} = client} = Accounts.create_client(valid_attrs)
assert client.name == "some name"
assert client.email == "some email"
assert client.company_name == "some company_name"
end
test "create_client/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Accounts.create_client(@invalid_attrs)
end
test "update_client/2 with valid data updates the client" do
client = client_fixture()
update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"}
assert {:ok, %Client{} = client} = Accounts.update_client(client, update_attrs)
assert client.name == "some updated name"
assert client.email == "some updated email"
assert client.company_name == "some updated company_name"
end
test "update_client/2 with invalid data returns error changeset" do
client = client_fixture()
assert {:error, %Ecto.Changeset{}} = Accounts.update_client(client, @invalid_attrs)
assert client == Accounts.get_client!(client.id)
end
test "delete_client/1 deletes the client" do
client = client_fixture()
assert {:ok, %Client{}} = Accounts.delete_client(client)
assert_raise Ecto.NoResultsError, fn -> Accounts.get_client!(client.id) end
end
test "change_client/1 returns a client changeset" do
client = client_fixture()
assert %Ecto.Changeset{} = Accounts.change_client(client)
end
end
describe "contractors" do
alias MyFirstElixirVibeCode.Accounts.Contractor
import MyFirstElixirVibeCode.AccountsFixtures
@invalid_attrs %{name: nil, email: nil, company_name: nil}
test "list_contractors/0 returns all contractors" do
contractor = contractor_fixture()
assert Accounts.list_contractors() == [contractor]
end
test "get_contractor!/1 returns the contractor with given id" do
contractor = contractor_fixture()
assert Accounts.get_contractor!(contractor.id) == contractor
end
test "create_contractor/1 with valid data creates a contractor" do
valid_attrs = %{name: "some name", email: "some email", company_name: "some company_name"}
assert {:ok, %Contractor{} = contractor} = Accounts.create_contractor(valid_attrs)
assert contractor.name == "some name"
assert contractor.email == "some email"
assert contractor.company_name == "some company_name"
end
test "create_contractor/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Accounts.create_contractor(@invalid_attrs)
end
test "update_contractor/2 with valid data updates the contractor" do
contractor = contractor_fixture()
update_attrs = %{name: "some updated name", email: "some updated email", company_name: "some updated company_name"}
assert {:ok, %Contractor{} = contractor} = Accounts.update_contractor(contractor, update_attrs)
assert contractor.name == "some updated name"
assert contractor.email == "some updated email"
assert contractor.company_name == "some updated company_name"
end
test "update_contractor/2 with invalid data returns error changeset" do
contractor = contractor_fixture()
assert {:error, %Ecto.Changeset{}} = Accounts.update_contractor(contractor, @invalid_attrs)
assert contractor == Accounts.get_contractor!(contractor.id)
end
test "delete_contractor/1 deletes the contractor" do
contractor = contractor_fixture()
assert {:ok, %Contractor{}} = Accounts.delete_contractor(contractor)
assert_raise Ecto.NoResultsError, fn -> Accounts.get_contractor!(contractor.id) end
end
test "change_contractor/1 returns a contractor changeset" do
contractor = contractor_fixture()
assert %Ecto.Changeset{} = Accounts.change_contractor(contractor)
end
end
end

View file

@ -0,0 +1,65 @@
defmodule MyFirstElixirVibeCode.ReviewsTest do
use MyFirstElixirVibeCode.DataCase
alias MyFirstElixirVibeCode.Reviews
describe "reviews" do
alias MyFirstElixirVibeCode.Reviews.Review
import MyFirstElixirVibeCode.ReviewsFixtures
@invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil}
test "list_reviews/0 returns all reviews" do
review = review_fixture()
assert Reviews.list_reviews() == [review]
end
test "get_review!/1 returns the review with given id" do
review = review_fixture()
assert Reviews.get_review!(review.id) == review
end
test "create_review/1 with valid data creates a review" do
valid_attrs = %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"}
assert {:ok, %Review{} = review} = Reviews.create_review(valid_attrs)
assert review.title == "some title"
assert review.rating == 42
assert review.content == "some content"
assert review.project_type == "some project_type"
end
test "create_review/1 with invalid data returns error changeset" do
assert {:error, %Ecto.Changeset{}} = Reviews.create_review(@invalid_attrs)
end
test "update_review/2 with valid data updates the review" do
review = review_fixture()
update_attrs = %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"}
assert {:ok, %Review{} = review} = Reviews.update_review(review, update_attrs)
assert review.title == "some updated title"
assert review.rating == 43
assert review.content == "some updated content"
assert review.project_type == "some updated project_type"
end
test "update_review/2 with invalid data returns error changeset" do
review = review_fixture()
assert {:error, %Ecto.Changeset{}} = Reviews.update_review(review, @invalid_attrs)
assert review == Reviews.get_review!(review.id)
end
test "delete_review/1 deletes the review" do
review = review_fixture()
assert {:ok, %Review{}} = Reviews.delete_review(review)
assert_raise Ecto.NoResultsError, fn -> Reviews.get_review!(review.id) end
end
test "change_review/1 returns a review changeset" do
review = review_fixture()
assert %Ecto.Changeset{} = Reviews.change_review(review)
end
end
end

View file

@ -0,0 +1,14 @@
defmodule MyFirstElixirVibeCodeWeb.ErrorHTMLTest do
use MyFirstElixirVibeCodeWeb.ConnCase, async: true
# Bring render_to_string/4 for testing custom views
import Phoenix.Template, only: [render_to_string: 4]
test "renders 404.html" do
assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "404", "html", []) == "Not Found"
end
test "renders 500.html" do
assert render_to_string(MyFirstElixirVibeCodeWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
end
end

View file

@ -0,0 +1,12 @@
defmodule MyFirstElixirVibeCodeWeb.ErrorJSONTest do
use MyFirstElixirVibeCodeWeb.ConnCase, async: true
test "renders 404" do
assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
end
test "renders 500" do
assert MyFirstElixirVibeCodeWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

View file

@ -0,0 +1,8 @@
defmodule MyFirstElixirVibeCodeWeb.PageControllerTest do
use MyFirstElixirVibeCodeWeb.ConnCase
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
end
end

View file

@ -0,0 +1,122 @@
defmodule MyFirstElixirVibeCodeWeb.ReviewLiveTest do
use MyFirstElixirVibeCodeWeb.ConnCase
import Phoenix.LiveViewTest
import MyFirstElixirVibeCode.ReviewsFixtures
@create_attrs %{title: "some title", rating: 42, content: "some content", project_type: "some project_type"}
@update_attrs %{title: "some updated title", rating: 43, content: "some updated content", project_type: "some updated project_type"}
@invalid_attrs %{title: nil, rating: nil, content: nil, project_type: nil}
defp create_review(_) do
review = review_fixture()
%{review: review}
end
describe "Index" do
setup [:create_review]
test "lists all reviews", %{conn: conn, review: review} do
{:ok, _index_live, html} = live(conn, ~p"/reviews")
assert html =~ "Listing Reviews"
assert html =~ review.title
end
test "saves new review", %{conn: conn} do
{:ok, index_live, _html} = live(conn, ~p"/reviews")
assert {:ok, form_live, _} =
index_live
|> element("a", "New Review")
|> render_click()
|> follow_redirect(conn, ~p"/reviews/new")
assert render(form_live) =~ "New Review"
assert form_live
|> form("#review-form", review: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert {:ok, index_live, _html} =
form_live
|> form("#review-form", review: @create_attrs)
|> render_submit()
|> follow_redirect(conn, ~p"/reviews")
html = render(index_live)
assert html =~ "Review created successfully"
assert html =~ "some title"
end
test "updates review in listing", %{conn: conn, review: review} do
{:ok, index_live, _html} = live(conn, ~p"/reviews")
assert {:ok, form_live, _html} =
index_live
|> element("#reviews-#{review.id} a", "Edit")
|> render_click()
|> follow_redirect(conn, ~p"/reviews/#{review}/edit")
assert render(form_live) =~ "Edit Review"
assert form_live
|> form("#review-form", review: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert {:ok, index_live, _html} =
form_live
|> form("#review-form", review: @update_attrs)
|> render_submit()
|> follow_redirect(conn, ~p"/reviews")
html = render(index_live)
assert html =~ "Review updated successfully"
assert html =~ "some updated title"
end
test "deletes review in listing", %{conn: conn, review: review} do
{:ok, index_live, _html} = live(conn, ~p"/reviews")
assert index_live |> element("#reviews-#{review.id} a", "Delete") |> render_click()
refute has_element?(index_live, "#reviews-#{review.id}")
end
end
describe "Show" do
setup [:create_review]
test "displays review", %{conn: conn, review: review} do
{:ok, _show_live, html} = live(conn, ~p"/reviews/#{review}")
assert html =~ "Show Review"
assert html =~ review.title
end
test "updates review and returns to show", %{conn: conn, review: review} do
{:ok, show_live, _html} = live(conn, ~p"/reviews/#{review}")
assert {:ok, form_live, _} =
show_live
|> element("a", "Edit")
|> render_click()
|> follow_redirect(conn, ~p"/reviews/#{review}/edit?return_to=show")
assert render(form_live) =~ "Edit Review"
assert form_live
|> form("#review-form", review: @invalid_attrs)
|> render_change() =~ "can&#39;t be blank"
assert {:ok, show_live, _html} =
form_live
|> form("#review-form", review: @update_attrs)
|> render_submit()
|> follow_redirect(conn, ~p"/reviews/#{review}")
html = render(show_live)
assert html =~ "Review updated successfully"
assert html =~ "some updated title"
end
end
end

38
test/support/conn_case.ex Normal file
View file

@ -0,0 +1,38 @@
defmodule MyFirstElixirVibeCodeWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use MyFirstElixirVibeCodeWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint MyFirstElixirVibeCodeWeb.Endpoint
use MyFirstElixirVibeCodeWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import MyFirstElixirVibeCodeWeb.ConnCase
end
end
setup tags do
MyFirstElixirVibeCode.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

58
test/support/data_case.ex Normal file
View file

@ -0,0 +1,58 @@
defmodule MyFirstElixirVibeCode.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use MyFirstElixirVibeCode.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias MyFirstElixirVibeCode.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import MyFirstElixirVibeCode.DataCase
end
end
setup tags do
MyFirstElixirVibeCode.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyFirstElixirVibeCode.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

View file

@ -0,0 +1,38 @@
defmodule MyFirstElixirVibeCode.AccountsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `MyFirstElixirVibeCode.Accounts` context.
"""
@doc """
Generate a client.
"""
def client_fixture(attrs \\ %{}) do
{:ok, client} =
attrs
|> Enum.into(%{
company_name: "some company_name",
email: "some email",
name: "some name"
})
|> MyFirstElixirVibeCode.Accounts.create_client()
client
end
@doc """
Generate a contractor.
"""
def contractor_fixture(attrs \\ %{}) do
{:ok, contractor} =
attrs
|> Enum.into(%{
company_name: "some company_name",
email: "some email",
name: "some name"
})
|> MyFirstElixirVibeCode.Accounts.create_contractor()
contractor
end
end

View file

@ -0,0 +1,23 @@
defmodule MyFirstElixirVibeCode.ReviewsFixtures do
@moduledoc """
This module defines test helpers for creating
entities via the `MyFirstElixirVibeCode.Reviews` context.
"""
@doc """
Generate a review.
"""
def review_fixture(attrs \\ %{}) do
{:ok, review} =
attrs
|> Enum.into(%{
content: "some content",
project_type: "some project_type",
rating: 42,
title: "some title"
})
|> MyFirstElixirVibeCode.Reviews.create_review()
review
end
end

2
test/test_helper.exs Normal file
View file

@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(MyFirstElixirVibeCode.Repo, :manual)