Setup first public version of the project

This commit is contained in:
JasterV 2025-12-11 18:18:11 +01:00
commit 8795de9ff7
120 changed files with 11708 additions and 0 deletions

218
.credo.exs Normal file
View file

@ -0,0 +1,218 @@
# This file contains the configuration for Credo and you are probably reading
# this after creating it with `mix credo.gen.config`.
#
# If you find anything wrong or unclear in this file, please report an
# issue on GitHub: https://github.com/rrrene/credo/issues
#
%{
#
# You can have as many configs as you like in the `configs:` field.
configs: [
%{
#
# Run any config using `mix credo -C <name>`. If no config name is given
# "default" is used.
#
name: "default",
#
# These are the files included in the analysis:
files: %{
#
# You can give explicit globs or simply directories.
# In the latter case `**/*.{ex,exs}` will be used.
#
included: [
"lib/",
"src/",
"test/",
"web/",
"apps/*/lib/",
"apps/*/src/",
"apps/*/test/",
"apps/*/web/"
],
excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"]
},
#
# Load and configure plugins here:
#
plugins: [],
#
# If you create your own checks, you must specify the source files for
# them here, so they can be loaded by Credo before running the analysis.
#
requires: [],
#
# If you want to enforce a style guide and need a more traditional linting
# experience, you can change `strict` to `true` below:
#
strict: false,
#
# To modify the timeout for parsing files, change this value:
#
parse_timeout: 5000,
#
# If you want to use uncolored output by default, you can change `color`
# to `false` below:
#
color: true,
#
# You can customize the parameters of any check by adding a second element
# to the tuple.
#
# To disable a check put `false` as second element:
#
# {Credo.Check.Design.DuplicatedCode, false}
#
checks: %{
enabled: [
#
## Consistency Checks
#
{Credo.Check.Consistency.ExceptionNames, []},
{Credo.Check.Consistency.LineEndings, []},
{Credo.Check.Consistency.ParameterPatternMatching, []},
{Credo.Check.Consistency.SpaceAroundOperators, []},
{Credo.Check.Consistency.SpaceInParentheses, []},
{Credo.Check.Consistency.TabsOrSpaces, []},
#
## Design Checks
#
# You can customize the priority of any check
# Priority values are: `low, normal, high, higher`
#
{Credo.Check.Design.AliasUsage,
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
{Credo.Check.Design.TagFIXME, []},
# You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero).
#
{Credo.Check.Design.TagTODO, [exit_status: 2]},
#
## Readability Checks
#
{Credo.Check.Readability.AliasOrder, []},
{Credo.Check.Readability.FunctionNames, []},
{Credo.Check.Readability.LargeNumbers, []},
{Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]},
{Credo.Check.Readability.ModuleAttributeNames, []},
{Credo.Check.Readability.ModuleDoc, []},
{Credo.Check.Readability.ModuleNames, []},
{Credo.Check.Readability.ParenthesesInCondition, []},
{Credo.Check.Readability.ParenthesesOnZeroArityDefs, []},
{Credo.Check.Readability.PipeIntoAnonymousFunctions, []},
{Credo.Check.Readability.PredicateFunctionNames, []},
{Credo.Check.Readability.PreferImplicitTry, []},
{Credo.Check.Readability.RedundantBlankLines, []},
{Credo.Check.Readability.Semicolons, []},
{Credo.Check.Readability.SpaceAfterCommas, []},
{Credo.Check.Readability.StringSigils, []},
{Credo.Check.Readability.TrailingBlankLine, []},
{Credo.Check.Readability.TrailingWhiteSpace, []},
{Credo.Check.Readability.UnnecessaryAliasExpansion, []},
{Credo.Check.Readability.VariableNames, []},
{Credo.Check.Readability.WithSingleClause, []},
#
## Refactoring Opportunities
#
{Credo.Check.Refactor.Apply, []},
{Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FilterCount, []},
{Credo.Check.Refactor.FilterFilter, []},
{Credo.Check.Refactor.FunctionArity, []},
{Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MapJoin, []},
{Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []},
{Credo.Check.Refactor.NegatedConditionsWithElse, []},
{Credo.Check.Refactor.Nesting, []},
{Credo.Check.Refactor.RedundantWithClauseResult, []},
{Credo.Check.Refactor.RejectReject, []},
{Credo.Check.Refactor.UnlessWithElse, []},
{Credo.Check.Refactor.WithClauses, []},
#
## Warnings
#
{Credo.Check.Warning.ApplicationConfigInModuleAttribute, []},
{Credo.Check.Warning.BoolOperationOnSameValues, []},
{Credo.Check.Warning.Dbg, []},
{Credo.Check.Warning.ExpensiveEmptyEnumCheck, []},
{Credo.Check.Warning.IExPry, []},
{Credo.Check.Warning.IoInspect, []},
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []},
{Credo.Check.Warning.OperationOnSameValues, []},
{Credo.Check.Warning.OperationWithConstantResult, []},
{Credo.Check.Warning.RaiseInsideRescue, []},
{Credo.Check.Warning.SpecWithStruct, []},
{Credo.Check.Warning.StructFieldAmount, []},
{Credo.Check.Warning.UnsafeExec, []},
{Credo.Check.Warning.UnusedEnumOperation, []},
{Credo.Check.Warning.UnusedFileOperation, []},
{Credo.Check.Warning.UnusedKeywordOperation, []},
{Credo.Check.Warning.UnusedListOperation, []},
{Credo.Check.Warning.UnusedPathOperation, []},
{Credo.Check.Warning.UnusedRegexOperation, []},
{Credo.Check.Warning.UnusedStringOperation, []},
{Credo.Check.Warning.UnusedTupleOperation, []},
{Credo.Check.Warning.WrongTestFileExtension, []}
],
disabled: [
#
# Checks scheduled for next check update (opt-in for now)
{Credo.Check.Refactor.UtcNowTruncate, []},
#
# Controversial and experimental checks (opt-in, just move the check to `:enabled`
# and be sure to use `mix credo --strict` to see low priority checks)
#
{Credo.Check.Consistency.MultiAliasImportRequireUse, []},
{Credo.Check.Consistency.UnusedVariableNames, []},
{Credo.Check.Design.DuplicatedCode, []},
{Credo.Check.Design.SkipTestWithoutComment, []},
{Credo.Check.Readability.AliasAs, []},
{Credo.Check.Readability.BlockPipe, []},
{Credo.Check.Readability.ImplTrue, []},
{Credo.Check.Readability.MultiAlias, []},
{Credo.Check.Readability.NestedFunctionCalls, []},
{Credo.Check.Readability.OneArityFunctionInPipe, []},
{Credo.Check.Readability.OnePipePerLine, []},
{Credo.Check.Readability.SeparateAliasRequire, []},
{Credo.Check.Readability.SingleFunctionToBlockPipe, []},
{Credo.Check.Readability.SinglePipe, []},
{Credo.Check.Readability.Specs, []},
{Credo.Check.Readability.StrictModuleLayout, []},
{Credo.Check.Readability.WithCustomTaggedTuple, []},
{Credo.Check.Refactor.ABCSize, []},
{Credo.Check.Refactor.AppendSingleItem, []},
{Credo.Check.Refactor.DoubleBooleanNegation, []},
{Credo.Check.Refactor.FilterReject, []},
{Credo.Check.Refactor.IoPuts, []},
{Credo.Check.Refactor.MapMap, []},
{Credo.Check.Refactor.ModuleDependencies, []},
{Credo.Check.Refactor.NegatedIsNil, []},
{Credo.Check.Refactor.PassAsyncInTestCases, []},
{Credo.Check.Refactor.PipeChainStart, []},
{Credo.Check.Refactor.RejectFilter, []},
{Credo.Check.Refactor.VariableRebinding, []},
{Credo.Check.Warning.LazyLogging, []},
{Credo.Check.Warning.LeakyEnvironment, []},
{Credo.Check.Warning.MapGetUnsafePass, []},
{Credo.Check.Warning.MixEnv, []},
{Credo.Check.Warning.UnsafeToAtom, []}
# {Credo.Check.Refactor.MapInto, []},
#
# Custom checks can be created using `mix credo.gen.check`.
#
]
}
}
]
}

18
.formatter.exs Normal file
View file

@ -0,0 +1,18 @@
[
import_deps: [
:oban,
:ash_admin,
:ash_authentication_phoenix,
:ash_authentication,
:ash_postgres,
:ash_phoenix,
:ash,
:reactor,
:ecto,
:ecto_sql,
:phoenix
],
subdirectories: ["priv/*/migrations"],
plugins: [Spark.Formatter, Phoenix.LiveView.HTMLFormatter],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

95
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,95 @@
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
env:
MIX_ENV: test
jobs:
ci:
# Service containers to run with `container-job`
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres
# Provide the password for postgres
env:
POSTGRES_PASSWORD: postgres
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
ports:
- 5432:5432
name: Run CI on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}
runs-on: ubuntu-latest
strategy:
matrix:
otp: ["27.3"]
elixir: ["1.18.3"]
steps:
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: ${{matrix.elixir}}
otp-version: ${{matrix.otp}}
- name: Checkout code
uses: actions/checkout@v5
# Step: Define how to cache deps. Restores existing cache if present.
- name: Cache deps
id: cache-deps
uses: actions/cache@v4
env:
cache-name: cache-spazio-solazzo-deps
with:
path: spazio-solazzo/deps
key: ${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ env.cache-name }}-${{ hashFiles('spazio-solazzo/**/mix.lock') }}
restore-keys: |
${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ env.cache-name }}-
# Step: Define how to cache the `_build` directory. After the first run,
# this speeds up tests runs a lot. This includes not re-compiling our
# project's downloaded deps every run.
- name: Cache compiled build
id: cache-build
uses: actions/cache@v4
env:
cache-name: cache-spazio-solazzo-compiled-build
with:
path: spazio-solazzo/_build
key: ${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ env.cache-name }}-${{ hashFiles('spazio-solazzo/**/mix.lock') }}
restore-keys: |
${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-${{ env.cache-name }}
${{ runner.os }}-${{matrix.otp}}-${{matrix.elixir}}-
- name: Install dependencies
run: mix deps.get
# Step: Compile the project treating any warnings as errors.
- name: Compiles without warnings
run: mix compile --warnings-as-errors
# Step: Check that the checked in code has already been formatted.
# This step fails if something was found unformatted.
- name: Check Formatting
run: mix format --check-formatted
# Step: Execute Credo
- name: Run Credo
run: mix credo
# Step: Execute the tests.
- name: Run tests
run: mix test

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").
spazio_solazzo-*.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/

10
.igniter.exs Normal file
View file

@ -0,0 +1,10 @@
# This is a configuration file for igniter.
# For option documentation, see https://hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html
# To keep it up to date, use `mix igniter.setup`
[
module_location: :outside_matching_folder,
extensions: [{Igniter.Extensions.Phoenix, []}],
deps_location: :last_list_literal,
source_folders: ["lib", "test/support"],
dont_move_files: [~r"lib/mix"]
]

373
AGENTS.md Normal file
View file

@ -0,0 +1,373 @@
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 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
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- 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: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`
### 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.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### 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")

373
CLAUDE.md Normal file
View file

@ -0,0 +1,373 @@
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 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
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- 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: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`
### 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.
- When updating an assign that should change content inside any streamed item(s), you MUST re-stream the items
along with the updated assign:
def handle_event("edit_message", %{"message_id" => message_id}, socket) do
message = Chat.get_message!(message_id)
edit_form = to_form(Chat.change_message(message, %{content: message.content}))
# re-insert message so @editing_message_id toggle logic takes effect for that stream item
{:noreply,
socket
|> stream_insert(:messages, message)
|> assign(:editing_message_id, String.to_integer(message_id))
|> assign(:edit_form, edit_form)}
end
And in the template:
<div id="messages" phx-update="stream">
<div :for={{id, message} <- @streams.messages} id={id} class="flex group">
{message.username}
<%= if @editing_message_id == message.id do %>
<%!-- Edit mode --%>
<.form for={@edit_form} id="edit-form-#{message.id}" phx-submit="save_edit">
...
</.form>
<% end %>
</div>
</div>
- **Never** use the deprecated `phx-update="append"` or `phx-update="prepend"` for collections
### LiveView JavaScript interop
- Remember anytime you use `phx-hook="MyHook"` and that JS hook manages its own DOM, you **must** also set the `phx-update="ignore"` attribute
- **Always** provide an unique DOM id alongside `phx-hook` otherwise a compiler error will be raised
LiveView hooks come in two flavors, 1) colocated js hooks for "inline" scripts defined inside HEEx,
and 2) external `phx-hook` annotations where JavaScript object literals are defined and passed to the `LiveSocket` constructor.
#### Inline colocated js hooks
**Never** write raw embedded `<script>` tags in heex as they are incompatible with LiveView.
Instead, **always use a colocated js hook script tag (`:type={Phoenix.LiveView.ColocatedHook}`)
when writing scripts inside the template**:
<input type="text" name="user[phone_number]" id="user-phone-number" phx-hook=".PhoneNumber" />
<script :type={Phoenix.LiveView.ColocatedHook} name=".PhoneNumber">
export default {
mounted() {
this.el.addEventListener("input", e => {
let match = this.el.value.replace(/\D/g, "").match(/^(\d{3})(\d{3})(\d{4})$/)
if(match) {
this.el.value = `${match[1]}-${match[2]}-${match[3]}`
}
})
}
}
</script>
- colocated hooks are automatically integrated into the app.js bundle
- colocated hooks names **MUST ALWAYS** start with a `.` prefix, i.e. `.PhoneNumber`
#### External phx-hook
External JS hooks (`<div id="myhook" phx-hook="MyHook">`) must be placed in `assets/js/` and passed to the
LiveSocket constructor:
const MyHook = {
mounted() { ... }
}
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { MyHook }
});
#### Pushing events between client and server
Use LiveView's `push_event/3` when you need to push events/data to the client for a phx-hook to handle.
**Always** return or rebind the socket on `push_event/3` when pushing events:
# re-bind socket so we maintain event state to be pushed
socket = push_event(socket, "my_event", %{...})
# or return the modified socket directly:
def handle_event("some_event", _, socket) do
{:noreply, push_event(socket, "my_event", %{...})}
end
Pushed events can then be picked up in a JS hook with `this.handleEvent`:
mounted() {
this.handleEvent("my_event", data => console.log("from server:", data));
}
Clients can also push an event to the server and receive a reply with `this.pushEvent`:
mounted() {
this.el.addEventListener("click", e => {
this.pushEvent("my_event", { one: 1 }, reply => console.log("got reply from server:", reply));
})
}
Where the server handled it via:
def handle_event("my_event", %{"one" => 1}, socket) do
{:reply, %{two: 2}, socket}
end
### 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")

241
LICENSE.md Normal file
View file

@ -0,0 +1,241 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
WITH COMMON CLAUSE RESTRICTION
This software is licensed under the Apache License, Version 2.0 (the
"License"), subject to the additional restriction described below
(the "Common Clause").
The Common Clause is an additional condition under Section 4 of the
Apache License, Version 2.0.
-----------------------------------------------------------------------
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the License,
as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights
under the License will not include, and the License does not grant to
you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or all of
the rights granted to you under the License to provide to third parties,
for a fee or other consideration (including without limitation fees
for hosting or consulting/support services related to the Software),
a product or service whose value derives, entirely or substantially,
from the functionality of the Software.
Any license notice or attribution required by the License must also
include this Commons Clause License Condition notice.
Licensor: Victor Martinez Montané
Software: Spazio Solazzo
-----------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# SpazioSolazzo
Spazio Solazzo is a cultural space in the heart of Palermo where people around the world gather to work, meet or play music.
Spazio Solazzo is managed by the Caravanserai cultural association and is found next to the Mojo Coliving project, which also makes part of Caravanserai.
This project allows Caravanserai to manage the different spaces inside Spazio Solazzo for rent. Spazio Solazzo at the moment is made out of three other spaces:
+ A Coworking space, where people can book desks to work and share during the day.
+ A meeting room that people and companies can book for their own meetings.
+ A music jam space that single musicians and bands can book for their rehearsals and other musical projects.
## Development
This site is build with Elixir.
I've decided to use the [Phoenix](https://www.phoenixframework.org/) web framework together with the [Ash](https://ash-hq.org/) framework,
together they allow me to build a rich interactive website with a rich data model easy to develop, test and maintain.
Personally, Phoenix is my go-to framework for any web projects that require any interactivity and real-time feedback to the users.
Ash helps modeling your domain and business logic in a very straight-forward way that integrates seamlessly with Phoenix.
### Setup DB
First you will need to have Docker and Docker compose installed. See [installation instructions](https://docs.docker.com/compose/install/).
Then, to spin up your local postgres database simply run:
```bash
docker compose up -d
```
### Setup Phoenix project
You'll need to make sure you have Elixir, Erlang and Phoenix installed in your system.
After that, download the dependencies:
```bash
mix deps.get
```
And setup the project with:
```bash
mix setup
```
This should run the DB migrations and seed the DB with mock data.
Now you should be ready to run the tests:
```bash
mix test
```
Or to run the compiler, formatter, tests, credo and more:
```bash
mix precommit
```
Now, to start your Phoenix server:
* 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 and you're ready to go!
## License
Copyright 2026 Victor Martinez Montané
Licensed under the Apache License, Version 2.0 (the "License"),
subject to the Commons Clause License Condition v1.0.
You may not use this file except in compliance with the License.
See the [LICENSE](./LICENSE.md) file for details.

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

@ -0,0 +1,177 @@
/* See the Tailwind configuration guide for advanced usage
https://tailwindcss.com/docs/configuration */
@import "tailwindcss" source(none);
@source "../../deps/ash_authentication_phoenix";
@source "../css";
@source "../js";
@source "../../lib/spazio_solazzo_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: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(25% 0.015 200);
--color-base-200: oklch(20% 0.012 200);
--color-base-300: oklch(15% 0.01 200);
--color-base-content: oklch(95% 0.01 200);
--color-primary: oklch(58% 0.15 180);
--color-primary-content: oklch(98% 0.01 180);
--color-secondary: oklch(58% 0.15 180);
--color-secondary-content: oklch(98% 0.01 180);
--color-accent: oklch(60% 0.12 175);
--color-accent-content: oklch(98% 0.01 175);
--color-neutral: oklch(30% 0.02 200);
--color-neutral-content: oklch(98% 0.01 200);
--color-info: oklch(58% 0.158 241.966);
--color-info-content: oklch(97% 0.013 236.62);
--color-success: oklch(60% 0.118 184.704);
--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: 1rem;
--radius-field: 1rem;
--radius-box: 1.5rem;
--size-selector: 0.21875rem;
--size-field: 0.21875rem;
--border: 1.5px;
--depth: 1;
--noise: 0;
}
@plugin "../vendor/daisyui-theme" {
name: "light";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(99% 0.005 200);
--color-base-200: oklch(97% 0.01 200);
--color-base-300: oklch(93% 0.015 200);
--color-base-content: oklch(30% 0.02 200);
--color-primary: oklch(58% 0.15 180);
--color-primary-content: oklch(98% 0.01 180);
--color-secondary: oklch(58% 0.15 180);
--color-secondary-content: oklch(98% 0.01 180);
--color-accent: oklch(62% 0.12 175);
--color-accent-content: oklch(98% 0.01 175);
--color-neutral: oklch(50% 0.02 200);
--color-neutral-content: oklch(98% 0.01 200);
--color-info: oklch(62% 0.18 210);
--color-info-content: oklch(97% 0.01 210);
--color-success: oklch(70% 0.14 140);
--color-success-content: oklch(98% 0.01 140);
--color-warning: oklch(78% 0.18 45);
--color-warning-content: oklch(20% 0.02 45);
--color-error: oklch(58% 0.20 20);
--color-error-content: oklch(96% 0.01 20);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1.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 */
/* New Design Color Palette - Mediterranean Blue Theme */
@theme {
/* Primary - Mediterranean Blue */
--color-primary: #0ea5e9;
--color-primary-hover: #0284c7;
/* Accent - Sicilian Lemon/Sun */
--color-accent: #facc15;
/* Light Mode Colors */
--color-background-light: #f8fafc;
--color-card-light: #ffffff;
--color-text-primary-light: #0f172a;
--color-text-secondary-light: #334155;
--color-border-light: #e2e8f0;
/* Dark Mode Colors */
--color-background-dark: #0f172a;
--color-card-dark: #1e293b;
--color-text-primary-dark: #f1f5f9;
--color-text-secondary-dark: #94a3b8;
--color-border-dark: #334155;
/* Slate Scale for consistency */
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
/* Sky Scale for primary variations */
--color-sky-100: #e0f2fe;
--color-sky-400: #38bdf8;
--color-sky-500: #0ea5e9;
--color-sky-600: #0284c7;
/* Yellow Scale for accent */
--color-yellow-100: #fef9c3;
--color-yellow-300: #fde047;
--color-yellow-400: #facc15;
--color-yellow-600: #ca8a04;
--color-yellow-700: #a16207;
--color-yellow-900: #713f12;
/* Typography */
--font-display: 'Inter', 'Montserrat', sans-serif;
}
/* Global Typography & Styles */
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-display);
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-display);
font-weight: 700;
}
.prose {
font-family: var(--font-display);
}

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/spazio_solazzo"
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));

16
compose.yml Normal file
View file

@ -0,0 +1,16 @@
services:
postgres:
image: 279066465364.dkr.ecr.eu-west-1.amazonaws.com/docker-hub/library/postgres:16
ports:
- "5432:5432"
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
networks:
- integration
networks:
integration:
name: "spazio-solazzo-integration"
driver: "bridge"

118
config/config.exs Normal file
View file

@ -0,0 +1,118 @@
# 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 :ash,
allow_forbidden_field_for_relationships_by_default?: true,
include_embedded_source_by_default?: false,
show_keysets_for_all_actions?: false,
default_page_type: :keyset,
policies: [no_filter_static_forbidden_reads?: false],
keep_read_action_loads_when_loading?: false,
default_actions_require_atomic?: true,
read_action_after_action_hooks_in_order?: true,
bulk_actions_default_to_errors?: true,
transaction_rollback_on_error?: true
config :spark,
formatter: [
remove_parens?: true,
"Ash.Resource": [
section_order: [
:admin,
:authentication,
:token,
:user_identity,
:postgres,
:resource,
:code_interface,
:actions,
:policies,
:pub_sub,
:preparations,
:changes,
:validations,
:multitenancy,
:attributes,
:relationships,
:calculations,
:aggregates,
:identities
]
],
"Ash.Domain": [
section_order: [:admin, :resources, :policies, :authorization, :domain, :execution]
]
]
config :spazio_solazzo,
ecto_repos: [SpazioSolazzo.Repo],
generators: [timestamp_type: :utc_datetime],
ash_domains: [SpazioSolazzo.Accounts, SpazioSolazzo.BookingSystem],
base_url: "http://localhost:4000",
ash_authentication: [return_error_on_invalid_magic_link_token?: true]
config :spazio_solazzo, Oban,
repo: SpazioSolazzo.Repo,
plugins: [Oban.Plugins.Pruner],
queues: [default: 10, booking_email: 10]
config :ash_phoenix, :pub_sub_module, SpazioSolazzo.PubSub
# Configure the endpoint
config :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [html: SpazioSolazzoWeb.ErrorHTML, json: SpazioSolazzoWeb.ErrorJSON],
layout: false
],
pubsub_server: SpazioSolazzo.PubSub,
live_view: [signing_salt: "7Fg6tOC2"]
# Configure 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 :spazio_solazzo, SpazioSolazzo.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
spazio_solazzo: [
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.12",
spazio_solazzo: [
args: ~w(
--input=assets/css/app.css
--output=priv/static/assets/css/app.css
),
cd: Path.expand("..", __DIR__)
]
# Configure 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"

100
config/dev.exs Normal file
View file

@ -0,0 +1,100 @@
import Config
config :ash, policies: [show_policy_breakdowns?: true]
# Configure your database
config :spazio_solazzo, SpazioSolazzo.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "spazio_solazzo_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 :spazio_solazzo, SpazioSolazzoWeb.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}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "mzFTGSFuc5eG42Fnw6iC+tcmNF9GvrmlwnU80L9KYig9B4uo18EIb9EY8wxwGIRW",
watchers: [
esbuild: {Esbuild, :install_and_run, [:spazio_solazzo, ~w(--sourcemap=inline --watch)]},
tailwind: {Tailwind, :install_and_run, [:spazio_solazzo, ~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.
# Reload browser tabs when matching files change.
config :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
live_reload: [
web_console_logger: true,
patterns: [
# Static assets, except user uploads
~r"priv/static/(?!uploads/).*\.(js|css|png|jpeg|jpg|gif|svg)$",
# Gettext translations
~r"priv/gettext/.*\.po$",
# Router, Controllers, LiveViews and LiveComponents
~r"lib/spazio_solazzo_web/router\.ex$",
~r"lib/spazio_solazzo_web/(controllers|live|components)/.*\.(ex|heex)$"
]
]
config :spazio_solazzo,
# Enable dev routes for dashboard and mailbox
dev_routes: true,
token_signing_secret: "kDoqYDWaus/U7vpVjnD/7UURoCROMaV2",
admin_email: "admin@myapp.com",
spazio_solazzo_email: "noreply@spaziosolazzo.com",
booking_token_signing_secret:
"43vbAIUx9+XswjhBrQ3uk2bapAYmu1WRR/h/zlFDxNd/CAfBypQXcvLv2bbR7TSf",
front_office_phone_number: "+39 36485928"
# 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

31
config/prod.exs Normal file
View file

@ -0,0 +1,31 @@
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 :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
cache_static_manifest: "priv/static/cache_manifest.json"
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
force_ssl: [rewrite_on: [:x_forwarded_proto]],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
# Configure 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.

135
config/runtime.exs Normal file
View file

@ -0,0 +1,135 @@
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/spazio_solazzo 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 :spazio_solazzo, SpazioSolazzoWeb.Endpoint, server: true
end
config :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
http: [port: String.to_integer(System.get_env("PORT", "4000"))]
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 :spazio_solazzo, SpazioSolazzo.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"
config :spazio_solazzo, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
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}
],
secret_key_base: secret_key_base
config :spazio_solazzo, SpazioSolazzo.Mailer,
adapter: Resend.Swoosh.Adapter,
api_key:
System.get_env("RESEND_API_KEY") ||
raise("""
Missing environment variable `RESEND_API_KEY`!.
""")
config :spazio_solazzo,
token_signing_secret:
System.get_env("TOKEN_SIGNING_SECRET") ||
raise("Missing environment variable `TOKEN_SIGNING_SECRET`!"),
booking_token_signing_secret:
System.get_env("TOKEN_SIGNING_SECRET") ||
raise("Missing environment variable `TOKEN_SIGNING_SECRET`!"),
admin_email:
System.get_env("ADMIN_EMAIL") ||
raise("Missing environment variable `ADMIN_EMAIL`!"),
spazio_solazzo_email:
System.get_env("SPAZIO_SOLAZZO_EMAIL") ||
raise("Missing environment variable `SPAZIO_SOLAZZO_EMAIL`!"),
front_office_phone_number:
System.get_env("FRONT_OFFICE_PHONE_NUMBER") ||
raise("Missing environment variable `FRONT_OFFICE_PHONE_NUMBER`!")
# 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.
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :spazio_solazzo, SpazioSolazzoWeb.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 :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

52
config/test.exs Normal file
View file

@ -0,0 +1,52 @@
import Config
config :spazio_solazzo, Oban, testing: :manual
config :bcrypt_elixir, log_rounds: 1
config :ash, policies: [show_policy_breakdowns?: true], disable_async?: true
config :spazio_solazzo,
token_signing_secret: "RfyHb7pU2R0WQY7TqdzLabS9LPPQosSq",
admin_email: "admin@myapp.com",
spazio_solazzo_email: "noreply@spaziosolazzo.com",
booking_token_signing_secret:
"43vbAIUx9+XswjhBrQ3uk2bapAYmu1WRR/h/zlFDxNd/CAfBypQXcvLv2bbR7TSf",
front_office_phone_number: "+39 36485928"
# 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 :spazio_solazzo, SpazioSolazzo.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "spazio_solazzo_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 :spazio_solazzo, SpazioSolazzoWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "qQdHaG3c/trjbfcoDF57u1wf+1pGzb82rxEhqKAzvHyaB4Z2U19MJivy7+wL756P",
server: false
# In test we don't send emails
config :spazio_solazzo, SpazioSolazzo.Mailer, adapter: Swoosh.Adapters.Local
# 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
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

17
lib/spazio_solazzo.ex Normal file
View file

@ -0,0 +1,17 @@
# Copyright 2026 Victor Martinez Montané
#
# Licensed under the Apache License, Version 2.0 (the "License"),
# subject to the Commons Clause License Condition v1.0.
# You may not use this file except in compliance with the License.
#
# See the LICENSE file for details.
#
defmodule SpazioSolazzo do
@moduledoc """
SpazioSolazzo 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,25 @@
defmodule SpazioSolazzo.Accounts do
@moduledoc """
The Accounts domain manages user authentication and authorization.
"""
use Ash.Domain,
otp_app: :spazio_solazzo,
extensions: [AshPhoenix]
resources do
resource SpazioSolazzo.Accounts.Token
resource SpazioSolazzo.Accounts.User do
define :request_magic_link, action: :request_magic_link, args: [:email]
define :sign_in_with_magic_link,
action: :sign_in_with_magic_link,
args: [:token, :remember_me, :name, :phone_number]
define :get_user_by_email, action: :read, get_by: [:email]
define :terminate_account, action: :terminate_account, args: [:delete_history]
define :update_profile, action: :update_profile, args: [:name, :phone_number]
end
end
end

View file

@ -0,0 +1,118 @@
defmodule SpazioSolazzo.Accounts.Token do
@moduledoc """
Authentication token resource for storing user session tokens.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication.TokenResource]
postgres do
table "tokens"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read]
read :expired do
description "Look up all expired tokens."
filter expr(expires_at < now())
end
read :get_token do
description "Look up a token by JTI or token, and an optional purpose."
get? true
argument :token, :string, sensitive?: true
argument :jti, :string, sensitive?: true
argument :purpose, :string, sensitive?: false
prepare AshAuthentication.TokenResource.GetTokenPreparation
end
action :revoked?, :boolean do
description "Returns true if a revocation token is found for the provided token"
argument :token, :string, sensitive?: true
argument :jti, :string, sensitive?: true
run AshAuthentication.TokenResource.IsRevoked
end
create :revoke_token do
description "Revoke a token. Creates a revocation token corresponding to the provided token."
accept [:extra_data]
argument :token, :string, allow_nil?: false, sensitive?: true
change AshAuthentication.TokenResource.RevokeTokenChange
end
create :revoke_jti do
description "Revoke a token by JTI. Creates a revocation token corresponding to the provided jti."
accept [:extra_data]
argument :subject, :string, allow_nil?: false, sensitive?: true
argument :jti, :string, allow_nil?: false, sensitive?: true
change AshAuthentication.TokenResource.RevokeJtiChange
end
create :store_token do
description "Stores a token used for the provided purpose."
accept [:extra_data, :purpose]
argument :token, :string, allow_nil?: false, sensitive?: true
change AshAuthentication.TokenResource.StoreTokenChange
end
destroy :expunge_expired do
description "Deletes expired tokens."
change filter expr(expires_at < now())
end
update :revoke_all_stored_for_subject do
description "Revokes all stored tokens for a specific subject."
accept [:extra_data]
argument :subject, :string, allow_nil?: false, sensitive?: true
change AshAuthentication.TokenResource.RevokeAllStoredForSubjectChange
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
description "AshAuthentication can interact with the token resource"
authorize_if always()
end
end
attributes do
attribute :jti, :string do
primary_key? true
public? true
allow_nil? false
sensitive? true
end
attribute :subject, :string do
allow_nil? false
public? true
end
attribute :expires_at, :utc_datetime do
allow_nil? false
public? true
end
attribute :purpose, :string do
allow_nil? false
public? true
end
attribute :extra_data, :map do
public? true
end
create_timestamp :created_at
update_timestamp :updated_at
end
end

View file

@ -0,0 +1,160 @@
defmodule SpazioSolazzo.Accounts.User do
@moduledoc """
Represents a user in the system with magic link authentication.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer],
extensions: [AshAuthentication]
authentication do
add_ons do
log_out_everywhere do
apply_on_password_change? true
end
end
tokens do
enabled? true
token_resource SpazioSolazzo.Accounts.Token
signing_secret SpazioSolazzo.Secrets
store_all_tokens? true
require_token_presence_for_authentication? true
end
strategies do
magic_link do
identity_field :email
registration_enabled? true
require_interaction? true
sender SpazioSolazzo.Accounts.User.Senders.SendMagicLinkEmail
end
remember_me :remember_me
end
end
postgres do
table "users"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read]
read :get_by_email do
description "Looks up a user by their email"
argument :email, :ci_string, allow_nil?: false
get? true
filter expr(email == ^arg(:email))
end
create :sign_in_with_magic_link do
description "Sign in or register a user with magic link."
argument :token, :string do
description "The token from the magic link that was sent to the user"
allow_nil? false
end
argument :remember_me, :boolean do
description "Whether to generate a remember me token"
allow_nil? true
end
argument :name, :string do
description "User's full name (required for new users)"
allow_nil? true
end
argument :phone_number, :string do
description "User's phone number (required for new users)"
allow_nil? true
end
upsert? true
upsert_identity :unique_email
upsert_fields [:email, :name, :phone_number]
# Uses the information from the token to create or sign in the user
change AshAuthentication.Strategy.MagicLink.SignInChange
# Conditionally validate name and phone_number for new users
change SpazioSolazzo.Accounts.User.Changes.ValidateRegistrationFields
change {AshAuthentication.Strategy.RememberMe.MaybeGenerateTokenChange,
strategy_name: :remember_me}
metadata :token, :string do
allow_nil? false
end
end
action :request_magic_link do
argument :email, :ci_string, allow_nil?: false
run AshAuthentication.Strategy.MagicLink.Request
end
update :update_profile do
description "Update user profile (name and phone number)"
accept [:name, :phone_number]
require_atomic? false
end
destroy :terminate_account do
description "Delete user account with optional booking data removal"
require_atomic? false
argument :delete_history, :boolean do
description "Whether to permanently delete all booking history"
default false
end
change SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion
end
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy action_type(:read) do
authorize_if always()
end
policy action_type(:update) do
authorize_if expr(id == ^actor(:id))
end
policy action_type(:destroy) do
authorize_if expr(id == ^actor(:id))
end
end
attributes do
uuid_primary_key :id
attribute :email, :ci_string do
allow_nil? false
public? true
end
attribute :name, :string do
allow_nil? false
public? true
end
attribute :phone_number, :string do
allow_nil? false
public? true
end
end
identities do
identity :unique_email, [:email]
end
end

View file

@ -0,0 +1,34 @@
defmodule SpazioSolazzo.Accounts.User.Changes.HandleBookingsOnAccountDeletion do
@moduledoc """
Handles booking cleanup when a user account is terminated.
- Cancels all future reserved bookings
- Either deletes all bookings or lets the database nullify them based on delete_history argument
"""
use Ash.Resource.Change
require Ash.Query
alias SpazioSolazzo.BookingSystem.Booking
alias SpazioSolazzo.BookingSystem
def change(changeset, _opts, _context) do
changeset
|> Ash.Changeset.before_action(fn changeset ->
user = changeset.data
delete_history = Ash.Changeset.get_argument(changeset, :delete_history)
Booking
|> Ash.Query.filter(
user_id == ^user.id and state == :reserved and date >= ^Date.utc_today()
)
|> BookingSystem.cancel_booking!()
if delete_history do
Booking
|> Ash.Query.filter(user_id == ^user.id)
|> BookingSystem.delete_booking!(authorize?: false)
end
changeset
end)
end
end

View file

@ -0,0 +1,38 @@
defmodule SpazioSolazzo.Accounts.User.Changes.ValidateRegistrationFields do
@moduledoc """
Conditionally validates that name and phone_number are present for new user registrations.
For existing users (upserts), these fields are not required.
"""
use Ash.Resource.Change
@impl true
def change(changeset, _opts, _context) do
email = Ash.Changeset.get_attribute(changeset, :email)
case SpazioSolazzo.Accounts.get_user_by_email(email, authorize?: false) do
{:ok, %{phone_number: phone, name: name}} ->
changeset
|> Ash.Changeset.force_change_attribute(:name, name)
|> Ash.Changeset.force_change_attribute(:phone_number, phone)
_ ->
name = Ash.Changeset.get_argument(changeset, :name)
phone = Ash.Changeset.get_argument(changeset, :phone_number)
changeset
|> validate_required_for_registration(:name, name)
|> validate_required_for_registration(:phone_number, phone)
end
end
defp validate_required_for_registration(changeset, field, value) do
if is_nil(value) || value == "" do
Ash.Changeset.add_error(
changeset,
Ash.Error.Changes.Required.exception(field: field, type: :argument)
)
else
Ash.Changeset.change_attribute(changeset, field, value)
end
end
end

View file

@ -0,0 +1,40 @@
defmodule SpazioSolazzo.Accounts.User.Senders.SendMagicLinkEmail do
@moduledoc """
Sends a magic link email
"""
use AshAuthentication.Sender
use SpazioSolazzoWeb, :verified_routes
import Swoosh.Email
alias SpazioSolazzo.Mailer
@impl true
def send(user_or_email, token, _) do
email =
case user_or_email do
%{email: email} -> email
email -> email
end
new()
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> to(to_string(email))
|> subject("Your login link")
|> html_body(body(token: token, email: email))
|> Mailer.deliver!()
end
defp body(params) do
magic_link_url = url(~p"/sign-in/callback?token=#{params[:token]}")
"""
<p>Hello, #{params[:email]}! Click this link to sign in:</p>
<p><a href="#{magic_link_url}">#{magic_link_url}</a></p>
"""
end
defp spazio_solazzo_email do
Application.get_env(:spazio_solazzo, :spazio_solazzo_email)
end
end

View file

@ -0,0 +1,36 @@
defmodule SpazioSolazzo.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 = [
SpazioSolazzoWeb.Telemetry,
SpazioSolazzo.Repo,
{Oban, Application.fetch_env!(:spazio_solazzo, Oban)},
{DNSCluster, query: Application.get_env(:spazio_solazzo, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: SpazioSolazzo.PubSub},
# Start a worker by calling: SpazioSolazzo.Worker.start_link(arg)
# {SpazioSolazzo.Worker, arg},
# Start to serve requests, typically the last entry
SpazioSolazzoWeb.Endpoint,
{AshAuthentication.Supervisor, [otp_app: :spazio_solazzo]}
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: SpazioSolazzo.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
SpazioSolazzoWeb.Endpoint.config_change(changed, removed)
:ok
end
end

View file

@ -0,0 +1,55 @@
defmodule SpazioSolazzo.BookingSystem do
@moduledoc """
Manages bookings, spaces, assets and time slots for the booking system.
"""
use Ash.Domain,
otp_app: :spazio_solazzo
resources do
resource SpazioSolazzo.BookingSystem.Space do
define :get_space_by_slug, action: :read, get_by: [:slug]
define :create_space, action: :create, args: [:name, :slug, :description]
end
resource SpazioSolazzo.BookingSystem.Asset do
define :get_asset_by_id, action: :read, get_by: [:id]
define :get_asset_by_space_id, action: :read, get_by: [:space_id]
define :get_space_assets, action: :get_space_assets, args: [:space_id]
define :create_asset, action: :create, args: [:name, :space_id]
end
resource SpazioSolazzo.BookingSystem.TimeSlotTemplate do
define :get_space_time_slots_by_date,
action: :get_space_time_slots_by_date,
args: [:space_id, :date]
define :create_time_slot_template,
action: :create,
args: [:start_time, :end_time, :day_of_week, :space_id]
end
resource SpazioSolazzo.BookingSystem.Booking do
define :list_active_asset_bookings_by_date,
action: :list_active_asset_bookings_by_date,
args: [:asset_id, :date]
define :create_booking,
action: :create,
args: [
:time_slot_template_id,
:asset_id,
:user_id,
:date,
:customer_name,
:customer_email,
:customer_phone,
:customer_comment
]
define :confirm_booking, action: :confirm_booking, args: []
define :cancel_booking, action: :cancel, args: []
define :delete_booking, action: :destroy, args: []
end
end
end

View file

@ -0,0 +1,43 @@
defmodule SpazioSolazzo.BookingSystem.Asset do
@moduledoc """
Represents bookable assets within a space, such as rooms or equipment.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
postgres do
table "assets"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read, create: :*]
read :get_space_assets do
argument :space_id, :string do
allow_nil? false
end
filter expr(space_id == ^arg(:space_id))
end
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
end
relationships do
belongs_to :space, SpazioSolazzo.BookingSystem.Space do
allow_nil? false
public? true
end
end
identities do
identity :unique_name_per_space, [:name, :space_id]
end
end

View file

@ -0,0 +1,192 @@
defmodule SpazioSolazzo.BookingSystem.Booking do
@moduledoc """
Represents a customer booking with state management for reservation lifecycle.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer,
notifiers: [Ash.Notifier.PubSub],
authorizers: [Ash.Policy.Authorizer],
extensions: [AshStateMachine]
alias SpazioSolazzo.BookingSystem.Booking.EmailWorker
postgres do
table "bookings"
repo SpazioSolazzo.Repo
references do
reference :user, on_delete: :nilify, index?: true
end
end
state_machine do
initial_states([:reserved])
default_initial_state(:reserved)
transitions do
transition(:confirm_booking, from: :reserved, to: :completed)
transition(:cancel, from: :reserved, to: :cancelled)
end
end
actions do
defaults [:read]
read :list_active_asset_bookings_by_date do
argument :asset_id, :uuid, allow_nil?: false
argument :date, :date, allow_nil?: false
filter expr(
asset_id == ^arg(:asset_id) and date == ^arg(:date) and
state in [:reserved, :completed]
)
end
create :create do
argument :time_slot_template_id, :uuid, allow_nil?: false
argument :asset_id, :uuid, allow_nil?: false
argument :user_id, :uuid, allow_nil?: false
argument :date, :date, allow_nil?: false
argument :customer_name, :string, allow_nil?: false
argument :customer_email, :string, allow_nil?: false
argument :customer_phone, :string, allow_nil?: false
argument :customer_comment, :string, allow_nil?: true
change manage_relationship(:time_slot_template_id, :time_slot_template,
type: :append_and_remove
)
change manage_relationship(:asset_id, :asset, type: :append_and_remove)
change manage_relationship(:user_id, :user, type: :append_and_remove, authorize?: false)
change fn changeset, _ctx ->
template_id = Ash.Changeset.get_argument(changeset, :time_slot_template_id)
case Ash.get(SpazioSolazzo.BookingSystem.TimeSlotTemplate, template_id) do
{:ok, template} ->
changeset
|> Ash.Changeset.force_change_attribute(:start_time, template.start_time)
|> Ash.Changeset.force_change_attribute(:end_time, template.end_time)
|> Ash.Changeset.force_change_attribute(
:date,
Ash.Changeset.get_argument(changeset, :date)
)
|> Ash.Changeset.force_change_attribute(
:customer_name,
Ash.Changeset.get_argument(changeset, :customer_name)
)
|> Ash.Changeset.force_change_attribute(
:customer_email,
Ash.Changeset.get_argument(changeset, :customer_email)
)
|> Ash.Changeset.force_change_attribute(
:customer_phone,
Ash.Changeset.get_argument(changeset, :customer_phone)
)
|> Ash.Changeset.force_change_attribute(
:customer_comment,
Ash.Changeset.get_argument(changeset, :customer_comment)
)
{:error, _} ->
Ash.Changeset.add_error(changeset,
field: :time_slot_template_id,
message: "Template not found"
)
end
end
change after_action(fn _changeset, booking, _ctx ->
%{
booking_id: booking.id,
customer_name: booking.customer_name,
customer_email: booking.customer_email,
customer_phone: booking.customer_phone,
customer_comment: booking.customer_comment,
date: Calendar.strftime(booking.date, "%A, %B %d"),
start_time: booking.start_time,
end_time: booking.end_time
}
|> EmailWorker.new()
|> Oban.insert!()
{:ok, booking}
end)
end
update :confirm_booking do
accept []
change transition_state(:completed)
end
update :cancel do
accept []
change transition_state(:cancelled)
end
destroy :destroy do
description "Delete a booking record"
primary? true
end
end
policies do
policy action([:cancel, :confirm_booking]) do
authorize_if always()
end
policy action_type(:destroy) do
authorize_if expr(:user_id == ^actor(:id))
end
policy action_type(:read) do
authorize_if always()
end
policy action_type(:create) do
authorize_if always()
end
end
pub_sub do
module SpazioSolazzoWeb.Endpoint
prefix "booking"
publish :create, ["created"]
publish :cancel, ["cancelled"]
end
attributes do
uuid_primary_key :id
attribute :date, :date, allow_nil?: false
attribute :customer_name, :string, allow_nil?: false
attribute :customer_email, :string, allow_nil?: false
attribute :start_time, :time, allow_nil?: false
attribute :end_time, :time, allow_nil?: false
attribute :customer_phone, :string, allow_nil?: false
attribute :customer_comment, :string, allow_nil?: true
attribute :state, :atom do
allow_nil? false
default :reserved
public? true
constraints one_of: [:reserved, :completed, :cancelled]
end
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :asset, SpazioSolazzo.BookingSystem.Asset
belongs_to :time_slot_template, SpazioSolazzo.BookingSystem.TimeSlotTemplate
belongs_to :user, SpazioSolazzo.Accounts.User do
allow_nil? true
end
end
end

View file

@ -0,0 +1,91 @@
defmodule SpazioSolazzo.BookingSystem.Booking.Email do
@moduledoc """
Sends booking confirmation emails to the customer and admin
"""
use Phoenix.Swoosh,
view: SpazioSolazzoWeb.EmailView,
layout: {SpazioSolazzoWeb.EmailView, :layout}
import Swoosh.Email
use SpazioSolazzoWeb, :verified_routes
alias SpazioSolazzo.BookingSystem.Booking.Token
def customer_confirmation(%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time
}) do
cancel_token = Token.generate_customer_cancel_token(booking_id)
cancel_url = url(~p"/bookings/cancel?token=#{cancel_token}")
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time,
cancel_url: cancel_url,
front_office_phone_number: front_office_phone_number(),
subject: "Booking Confirmed: #{date}"
}
new()
|> to({customer_name, customer_email})
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("customer_confirmation.html", assigns)
end
# --- Admin Email ---
def admin_notification(%{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time,
admin_email: admin_email
}) do
tokens = Token.generate_admin_tokens(booking_id)
confirm_url = url(~p"/bookings/confirm?token=#{tokens.confirm_token}")
cancel_url = url(~p"/bookings/cancel?token=#{tokens.cancel_token}")
assigns = %{
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time,
confirm_url: confirm_url,
cancel_url: cancel_url,
subject: "New Booking Action Required: #{customer_name}"
}
new()
|> to(admin_email)
|> from({"Spazio Solazzo", spazio_solazzo_email()})
|> subject(assigns.subject)
|> render_body("admin_notification.html", assigns)
end
defp spazio_solazzo_email do
Application.get_env(:spazio_solazzo, :spazio_solazzo_email)
end
defp front_office_phone_number do
Application.get_env(:spazio_solazzo, :front_office_phone_number)
end
end

View file

@ -0,0 +1,49 @@
defmodule SpazioSolazzo.BookingSystem.Booking.EmailWorker do
@moduledoc """
Sends booking confirmation emails to customers and notification emails to administrators.
"""
use Oban.Worker, queue: :booking_email, max_attempts: 1
alias SpazioSolazzo.BookingSystem.Booking.Email
@impl Oban.Worker
def perform(%Oban.Job{
args: %{
"booking_id" => booking_id,
"customer_name" => customer_name,
"customer_email" => customer_email,
"customer_phone" => customer_phone,
"customer_comment" => customer_comment,
"date" => date,
"start_time" => start_time,
"end_time" => end_time
}
}) do
email_data = %{
booking_id: booking_id,
customer_name: customer_name,
customer_email: customer_email,
customer_phone: customer_phone,
customer_comment: customer_comment,
date: date,
start_time: start_time,
end_time: end_time,
admin_email: admin_email()
}
email_data
|> Email.customer_confirmation()
|> SpazioSolazzo.Mailer.deliver!()
email_data
|> Email.admin_notification()
|> SpazioSolazzo.Mailer.deliver!()
:ok
end
defp admin_email do
Application.get_env(:spazio_solazzo, :admin_email)
end
end

View file

@ -0,0 +1,30 @@
defmodule SpazioSolazzo.BookingSystem.Booking.Token do
@moduledoc """
Generates secure, signed tokens for email actions.
"""
alias SpazioSolazzoWeb.Endpoint
def generate_customer_cancel_token(booking_id) do
payload = %{booking_id: booking_id, role: :customer, action: :cancel}
Phoenix.Token.sign(Endpoint, signing_salt(), payload)
end
def generate_admin_tokens(booking_id) do
confirm_payload = %{booking_id: booking_id, role: :admin, action: :confirm}
cancel_payload = %{booking_id: booking_id, role: :admin, action: :cancel}
%{
confirm_token: Phoenix.Token.sign(Endpoint, signing_salt(), confirm_payload),
cancel_token: Phoenix.Token.sign(Endpoint, signing_salt(), cancel_payload)
}
end
# Helper to verify tokens in the Controller later
def verify(token) do
Phoenix.Token.verify(Endpoint, signing_salt(), token)
end
defp signing_salt() do
Application.get_env(:spazio_solazzo, :booking_token_signing_secret)
end
end

View file

@ -0,0 +1,31 @@
defmodule SpazioSolazzo.BookingSystem.Space do
@moduledoc """
Represents a physical or virtual space that contains bookable assets.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
postgres do
table "spaces"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read, create: :*]
end
attributes do
uuid_primary_key :id
attribute :name, :string, allow_nil?: false, public?: true
attribute :description, :string, allow_nil?: false, public?: true
attribute :slug, :string, allow_nil?: false, public?: true
end
identities do
identity :unique_name, [:name]
identity :unique_slug, [:slug]
end
end

View file

@ -0,0 +1,58 @@
defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate do
@moduledoc """
Defines recurring time slots for bookings based on day of the week.
"""
use Ash.Resource,
otp_app: :spazio_solazzo,
domain: SpazioSolazzo.BookingSystem,
data_layer: AshPostgres.DataLayer
alias SpazioSolazzo.BookingSystem.TimeSlotTemplate.Changes
postgres do
table "time_slot_templates"
repo SpazioSolazzo.Repo
end
actions do
defaults [:read, :destroy]
create :create do
accept [:start_time, :end_time, :space_id, :day_of_week]
change {Changes.PreventCreationOverlap, []}
end
read :get_space_time_slots_by_date do
argument :space_id, :string do
allow_nil? false
end
argument :date, :date do
allow_nil? false
end
filter expr(space_id == ^arg(:space_id))
prepare SpazioSolazzo.BookingSystem.TimeSlotTemplate.Preparations.FilterByDate
end
end
attributes do
uuid_primary_key :id
attribute :start_time, :time, allow_nil?: false, public?: true
attribute :end_time, :time, allow_nil?: false, public?: true
attribute :day_of_week, :atom do
allow_nil? false
public? true
constraints one_of: [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
end
end
relationships do
belongs_to :space, SpazioSolazzo.BookingSystem.Space do
allow_nil? false
public? true
end
end
end

View file

@ -0,0 +1,45 @@
defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Changes.PreventCreationOverlap do
@moduledoc false
use Ash.Resource.Change
alias Ash.Changeset
alias Ash.Resource.Change
require Ash.Query
alias SpazioSolazzo.BookingSystem.TimeSlotTemplate
@impl true
@spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
def change(changeset, _opts, _context) do
space_id = Ash.Changeset.get_attribute(changeset, :space_id)
start_time = Ash.Changeset.get_attribute(changeset, :start_time)
end_time = Ash.Changeset.get_attribute(changeset, :end_time)
day_of_week = Ash.Changeset.get_attribute(changeset, :day_of_week)
overlapping =
TimeSlotTemplate
|> Ash.Query.filter(space_id == ^space_id)
|> Ash.Query.filter(day_of_week == ^day_of_week)
|> Ash.Query.filter(start_time < ^end_time and end_time > ^start_time)
|> Ash.read()
case overlapping do
{:ok, []} ->
changeset
{:ok, _} ->
Changeset.add_error(changeset,
field: :base,
message: "time slot overlaps with existing template for this space and day"
)
{:error, err} ->
Changeset.add_error(changeset,
field: :base,
message: "failed to validate overlap: #{inspect(err)}"
)
end
end
end

View file

@ -0,0 +1,31 @@
defmodule SpazioSolazzo.BookingSystem.TimeSlotTemplate.Preparations.FilterByDate do
@moduledoc """
Filters time slot templates by matching the day of the week from a given date.
"""
use Ash.Resource.Preparation
@impl true
def prepare(query, _opts, _context) do
case Ash.Query.get_argument(query, :date) do
nil ->
query
date ->
day_of_week = parse_date_to_week_day(date)
Ash.Query.filter(query, day_of_week == ^day_of_week)
end
end
defp parse_date_to_week_day(date) do
case Date.day_of_week(date) do
1 -> :monday
2 -> :tuesday
3 -> :wednesday
4 -> :thursday
5 -> :friday
6 -> :saturday
7 -> :sunday
end
end
end

View file

@ -0,0 +1,16 @@
defmodule SpazioSolazzo.CalendarExt do
@moduledoc """
Extension module for Calendar with helper date and time formatting functions
"""
def format_date(date) do
Calendar.strftime(date, "%B %d, %Y")
end
def format_time_range(%{start_time: start_time, end_time: end_time}) do
start_time = Calendar.strftime(start_time, "%I:%M %p")
end_time = Calendar.strftime(end_time, "%I:%M %p")
"#{start_time} - #{end_time}"
end
end

View file

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

View file

@ -0,0 +1,22 @@
defmodule SpazioSolazzo.Repo do
use AshPostgres.Repo,
otp_app: :spazio_solazzo
@impl true
def installed_extensions do
# Add extensions here, and the migration generator will install them.
["ash-functions", "citext"]
end
# Don't open unnecessary transactions
# will default to `false` in 4.0
@impl true
def prefer_transaction? do
false
end
@impl true
def min_pg_version do
%Version{major: 16, minor: 0, patch: 0}
end
end

View file

@ -0,0 +1,16 @@
defmodule SpazioSolazzo.Secrets do
@moduledoc """
Provides access to application secrets for authentication.
"""
use AshAuthentication.Secret
def secret_for(
[:authentication, :tokens, :signing_secret],
SpazioSolazzo.Accounts.User,
_opts,
_context
) do
Application.fetch_env(:spazio_solazzo, :token_signing_secret)
end
end

122
lib/spazio_solazzo_web.ex Normal file
View file

@ -0,0 +1,122 @@
# Copyright 2026 Victor Martinez Montané
#
# Licensed under the Apache License, Version 2.0 (the "License"),
# subject to the Commons Clause License Condition v1.0.
# You may not use this file except in compliance with the License.
#
# See the LICENSE file for details.
#
defmodule SpazioSolazzoWeb 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 SpazioSolazzoWeb, :controller
use SpazioSolazzoWeb, :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: SpazioSolazzoWeb.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: SpazioSolazzoWeb.Gettext
# HTML escaping functionality
import Phoenix.HTML
# Core UI components
import SpazioSolazzoWeb.CoreComponents
# Common modules used in templates
alias Phoenix.LiveView.JS
alias SpazioSolazzoWeb.Layouts
# Routes generation with the ~p sigil
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: SpazioSolazzoWeb.Endpoint,
router: SpazioSolazzoWeb.Router,
statics: SpazioSolazzoWeb.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,114 @@
defmodule SpazioSolazzoWeb.BookingComponents do
@moduledoc """
Reusable components for the booking flow.
"""
use Phoenix.Component
alias SpazioSolazzo.CalendarExt
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_close, :any, required: true
@doc """
Success modal displayed when a booking is completed.
"""
def booking_confirmation_modal(assigns) do
~H"""
<div
:if={@show}
id={@id}
class="relative z-50"
role="dialog"
aria-modal="true"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<div
id={"#{@id}-container"}
class="relative transform overflow-hidden rounded-3xl bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"
>
<div>
<div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100 dark:bg-teal-900/30">
<svg
class="h-6 w-6 text-teal-600 dark:text-teal-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div class="mt-3 text-center sm:mt-5">
<h3 class="text-lg font-semibold leading-6 text-gray-900 dark:text-white">
Booking Successful!
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-gray-400">
Your booking has been confirmed. You will receive a confirmation email shortly.
</p>
</div>
</div>
</div>
<div class="mt-5 sm:mt-6">
<button
phx-click={@on_close}
type="button"
class="inline-flex w-full justify-center rounded-2xl bg-teal-600 px-3 py-3 text-sm font-semibold text-white shadow-lg hover:bg-teal-700 hover:shadow-xl focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600 transition-all"
>
Got it!
</button>
</div>
</div>
</div>
</div>
</div>
"""
end
attr :time_slot, :map, required: true
attr :booked, :boolean, required: true
@doc """
Renders time slot buttons in different sizes showing availability status.
"""
def time_slot(assigns) do
~H"""
<button
phx-click={unless @booked, do: "select_slot"}
phx-value-time_slot_id={@time_slot.id}
disabled={@booked}
class={[
"group w-full flex items-center justify-between p-4 rounded-xl border-2 transition-all duration-200",
if(@booked,
do:
"border-slate-300 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 cursor-not-allowed opacity-75",
else:
"border-sky-500/40 hover:border-sky-500 bg-transparent hover:bg-sky-500/5 dark:hover:bg-sky-500/10 cursor-pointer"
)
]}
>
<span class={[
"text-lg font-bold transition-colors",
if(@booked,
do: "text-slate-500 dark:text-slate-400",
else: "text-slate-900 dark:text-white group-hover:text-sky-500"
)
]}>
{CalendarExt.format_time_range(@time_slot)}
</span>
<span class={[
"text-xs font-medium",
if(@booked, do: "text-slate-500", else: "text-sky-500")
]}>
{if @booked, do: "Booked", else: "Available"}
</span>
</button>
"""
end
end

View file

@ -0,0 +1,657 @@
defmodule SpazioSolazzoWeb.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: SpazioSolazzoWeb.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, :any
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 radio, are best
written directly in your templates.
## Examples
```heex
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
```
## Select type
When using `type="select"`, you must pass the `options` and optionally
a `value` to mark which option should be preselected.
```heex
<.input field={@form[:user_type]} type="select" options={["Admin": "admin", "User": "user"]} />
```
For more information on what kind of data can be passed to `options` see
[`options_for_select`](https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#options_for_select/2).
"""
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 hidden)
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, :any, default: nil, doc: "the input class to use over defaults"
attr :error_class, :any, 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: "hidden"} = assigns) do
~H"""
<input type="hidden" id={@id} name={@name} value={@value} {@rest} />
"""
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]}
form={@rest[:form]}
/>
<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
# 🎯 Specific clause for type="date" with inline classes
def input(%{type: "date"} = assigns) do
~H"""
<div>
<label>
<span
:if={@label}
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{@label}
</span>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={
[
# Use @class if provided, otherwise use the very long inline string:
@class ||
"block w-full max-w-xs px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white",
@errors != [] && (@error_class || "input-error")
]
}
{@rest}
/>
</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 text-gray-900 dark:text-gray-100">{@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, :any, default: "size-4"
def icon(%{name: "hero-" <> _} = assigns) do
~H"""
<span class={[@name, @class]} />
"""
end
@doc """
Renders a modal.
## Examples
<.modal id="confirm-modal">
Are you sure?
<:confirm>OK</:confirm>
<:cancel>Cancel</:cancel>
</.modal>
"""
attr :id, :string, required: true
attr :show, :boolean, default: false
attr :on_cancel, JS, default: %JS{}
slot :inner_block, required: true
slot :title
slot :subtitle
slot :confirm
slot :cancel
def modal(assigns) do
~H"""
<div
id={@id}
phx-mounted={@show && show_modal(@id)}
phx-remove={hide_modal(@id)}
data-cancel={JS.exec(@on_cancel, "phx-remove")}
class="relative z-50 hidden"
>
<div
id={"#{@id}-bg"}
class="fixed inset-0 bg-zinc-50/90 dark:bg-gray-900/90 transition-opacity"
aria-hidden="true"
/>
<div
class="fixed inset-0 overflow-y-auto"
aria-labelledby={"#{@id}-title"}
aria-describedby={"#{@id}-description"}
role="dialog"
aria-modal="true"
tabindex="0"
>
<div class="flex min-h-full items-center justify-center">
<div class="w-full max-w-md p-4 sm:p-6 lg:py-8">
<.focus_wrap
id={"#{@id}-container"}
phx-window-keydown={JS.exec("data-cancel", to: "##{@id}")}
phx-key="escape"
phx-click-away={JS.exec("data-cancel", to: "##{@id}")}
class="hidden relative rounded-2xl bg-white dark:bg-gray-800 p-8 shadow-2xl transition"
>
<div class="absolute top-6 right-6">
<button
phx-click={JS.exec("data-cancel", to: "##{@id}")}
type="button"
class="-m-3 flex-none p-3 opacity-40 hover:opacity-60 text-gray-600 dark:text-gray-400"
aria-label="close"
>
<.icon name="hero-x-mark-solid" class="size-5" />
</button>
</div>
<div id={"#{@id}-content"}>
<header :if={@title != []}>
<h1 id={"#{@id}-title"} class="text-2xl font-bold text-zinc-900 dark:text-white">
{render_slot(@title)}
</h1>
<p
:if={@subtitle != []}
id={"#{@id}-description"}
class="mt-2 text-sm leading-6 text-zinc-600 dark:text-gray-300"
>
{render_slot(@subtitle)}
</p>
</header>
{render_slot(@inner_block)}
<div :if={@confirm != [] or @cancel != []} class="mt-6 flex items-center gap-3">
<.button
:for={confirm <- @confirm}
id={"#{@id}-confirm"}
phx-click={confirm[:phx_click]}
phx-disable-with
class="flex-1"
>
{render_slot(confirm)}
</.button>
<.button
:for={cancel <- @cancel}
phx-click={JS.exec(@on_cancel, "phx-remove")}
type="button"
class="flex-1"
>
{render_slot(cancel)}
</.button>
</div>
</div>
</.focus_wrap>
</div>
</div>
</div>
</div>
"""
end
## JS Commands
def show_modal(js \\ %JS{}, id) when is_binary(id) do
js
|> JS.show(to: "##{id}")
|> JS.show(
to: "##{id}-bg",
transition: {"transition-all ease-out duration-300", "opacity-0", "opacity-100"}
)
|> show("##{id}-container")
|> JS.add_class("overflow-hidden", to: "body")
|> JS.focus_first(to: "##{id}-content")
end
def hide_modal(js \\ %JS{}, id) do
js
|> JS.hide(
to: "##{id}-bg",
transition: {"transition-all ease-in duration-200", "opacity-100", "opacity-0"}
)
|> hide("##{id}-container")
|> JS.hide(to: "##{id}", transition: {"block", "block", "hidden"})
|> JS.remove_class("overflow-hidden", to: "body")
|> JS.pop_focus()
end
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(SpazioSolazzoWeb.Gettext, "errors", msg, msg, count, opts)
else
Gettext.dgettext(SpazioSolazzoWeb.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,250 @@
defmodule SpazioSolazzoWeb.LandingComponents do
@moduledoc """
Reusable components for landing pages (coworking, meeting room, music room).
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
import Phoenix.Component
use Phoenix.VerifiedRoutes,
endpoint: SpazioSolazzoWeb.Endpoint,
router: SpazioSolazzoWeb.Router,
statics: SpazioSolazzoWeb.static_paths()
@doc """
Renders a feature card with icon, title, and description.
## Examples
<.feature_card
icon="tv"
title="4K Presentation"
description="Crystal clear 65&quot; monitor ready for your slide decks."
color="sky"
/>
"""
attr :icon, :string, required: true
attr :title, :string, required: true
attr :description, :string, required: true
attr :color, :string,
default: "sky",
doc: "Color scheme: sky, orange, yellow, emerald, indigo, purple"
def feature_card(assigns) do
~H"""
<div class="bg-white dark:bg-slate-900 p-8 rounded-2xl border border-slate-100 dark:border-slate-800 shadow-sm hover:shadow-lg hover:border-teal-500/30 dark:hover:border-teal-500/30 transition-all duration-300 group">
<div class={[
"w-12 h-12 rounded-xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform",
color_classes(@color)
]}>
<.icon name={@icon} class="w-7 h-7" />
</div>
<h3 class="text-xl font-bold text-slate-900 dark:text-white mb-3">
{@title}
</h3>
<p class="text-slate-600 dark:text-slate-400 leading-relaxed">
{@description}
</p>
</div>
"""
end
defp color_classes("sky"), do: "bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-400"
defp color_classes("orange"),
do: "bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400"
defp color_classes("yellow"),
do: "bg-yellow-100 dark:bg-yellow-900/30 text-yellow-600 dark:text-yellow-400"
defp color_classes("emerald"),
do: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
defp color_classes("indigo"),
do: "bg-indigo-100 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400"
defp color_classes("purple"),
do: "bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400"
defp color_classes(_),
do: "bg-slate-100 dark:bg-slate-900/30 text-slate-600 dark:text-slate-400"
@doc """
Renders a house rules section with a list of rules.
## Examples
<.house_rules title="House Rules">
<:rule>Please clean the whiteboard after use.</:rule>
<:rule>Outside food is allowed, but please be tidy.</:rule>
</.house_rules>
"""
attr :title, :string, default: "House Rules"
slot :rule, required: true
def house_rules(assigns) do
~H"""
<section class="py-16 px-6">
<div class="mx-auto max-w-[1000px] bg-orange-50 dark:bg-slate-800/50 rounded-3xl p-8 md:p-12 border border-orange-100 dark:border-slate-700/50">
<div class="flex flex-col md:flex-row gap-8 items-center justify-center">
<div class="flex-1 md:flex-none w-full md:w-auto">
<h3 class="text-2xl font-bold text-slate-900 dark:text-white mb-4">
{@title}
</h3>
<ul class="space-y-3">
<li
:for={rule <- @rule}
class="flex items-start gap-3 text-slate-600 dark:text-slate-300"
>
<.icon
name="hero-check-circle"
class="w-5 h-5 text-orange-500 dark:text-orange-400 shrink-0 mt-0.5"
/>
<span>{render_slot(rule)}</span>
</li>
</ul>
</div>
</div>
</div>
</section>
"""
end
@doc """
Renders a page header with title, description, booking button, carousel, and capacity info.
## Examples
<.page_header
title="Meeting Room"
description="A private, sun-drenched sanctuary designed for focus and collaboration."
booking_path={~p"/book/asset/\#{@asset.id}"}
price="€40"
price_unit="hour"
capacity="Up to 8 People"
images={@images}
/>
"""
slot :title, required: true
slot :description, required: true
attr :booking_path, :string, required: true
attr :booking_label, :string, default: "Book This Room"
attr :price, :string, required: true
attr :price_unit, :string, default: "hour"
attr :capacity, :string, required: true
attr :images, :list, default: []
def page_header(assigns) do
~H"""
<section class="relative pt-6 md:pt-10 pb-16 px-6">
<div class="mx-auto max-w-[1200px]">
<div class="mb-6 flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<.link
navigate={~p"/"}
class="hover:text-sky-500 dark:hover:text-yellow-400 transition-colors flex items-center gap-1"
>
<.icon name="hero-arrow-left" class="w-4 h-4" /> Back to Home
</.link>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-16 items-center">
<div class="order-2 lg:order-1 flex flex-col gap-6">
<div>
<h1 class="text-4xl md:text-5xl lg:text-6xl font-black text-slate-900 dark:text-white leading-[1.1] tracking-tight">
{render_slot(@title)}
</h1>
</div>
<p class="text-lg text-slate-600 dark:text-slate-300 leading-relaxed max-w-xl">
{render_slot(@description)}
</p>
<div class="flex flex-col sm:flex-row gap-4 pt-2">
<.link
navigate={@booking_path}
class="h-14 px-8 rounded-2xl bg-sky-500 hover:bg-sky-600 dark:bg-teal-500 dark:hover:bg-teal-600 text-white text-lg font-bold transition-all shadow-xl shadow-sky-500/30 dark:shadow-teal-500/30 flex items-center justify-center gap-3 w-full sm:w-auto hover:-translate-y-1"
>
<span>{@booking_label}</span>
<.icon name="hero-arrow-right" class="w-5 h-5" />
</.link>
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 px-4 h-14 w-full sm:w-auto justify-center">
<span class="text-2xl font-bold text-slate-900 dark:text-white">{@price}</span>
<span class="text-sm">/ {@price_unit}</span>
</div>
</div>
</div>
<div class="order-1 lg:order-2 relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-orange-400 to-yellow-400 rounded-3xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200">
</div>
<div class="relative overflow-hidden rounded-2xl aspect-[4/3] shadow-2xl shadow-black/40">
<.live_component
module={SpazioSolazzoWeb.CarouselLiveComponent}
id="page-header-carousel"
images={@images}
/>
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/80 via-transparent to-transparent">
</div>
<div class="absolute bottom-6 left-6 right-6 flex justify-between items-end">
<div>
<span class="block text-yellow-400 font-bold text-sm mb-1 tracking-wide">
CAPACITY
</span>
<span class="text-white font-bold text-xl flex items-center gap-2">
<.icon name="hero-user-group" class="w-6 h-6" />
{@capacity}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
"""
end
@doc """
Renders an amenities/features section with a grid of feature cards.
## Examples
<.features_section title="Everything you need" description="Top-tier amenities...">
<:feature icon="tv" title="4K Presentation" description="..." color="sky" />
<:feature icon="video-camera" title="Video Conferencing" description="..." color="orange" />
</.features_section>
"""
attr :title, :string, required: true
attr :description, :string, required: true
slot :feature, required: true do
attr :icon, :string, required: true
attr :title, :string, required: true
attr :description, :string, required: true
attr :color, :string
end
def features_section(assigns) do
~H"""
<section class="py-20 bg-slate-50 dark:bg-slate-800/30 border-y border-slate-200 dark:border-slate-800">
<div class="mx-auto max-w-[1200px] px-6">
<div class="text-center max-w-2xl mx-auto mb-16">
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-4">
{@title}
</h2>
<p class="text-slate-600 dark:text-slate-400">
{@description}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<.feature_card
:for={feature <- @feature}
icon={feature.icon}
title={feature.title}
description={feature.description}
color={Map.get(feature, :color, "sky")}
/>
</div>
</div>
</section>
"""
end
end

View file

@ -0,0 +1,303 @@
defmodule SpazioSolazzoWeb.Layouts do
@moduledoc """
This module holds layouts and related functionality
used by your application.
"""
use SpazioSolazzoWeb, :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)"
attr :current_user, :map,
default: nil,
doc: "the current authenticated user"
slot :inner_block, required: true
def app(assigns) do
~H"""
<.app_header current_user={@current_user} />
<main class="bg-slate-50 dark:bg-slate-900 flex-1 relative transition-colors duration-300">
{render_slot(@inner_block)}
</main>
<.flash_group flash={@flash} />
<.footer />
"""
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"""
<button
class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400 transition-colors"
phx-click={
JS.dispatch("phx:set-theme",
detail: %{theme: "toggle"}
)
}
title="Toggle Dark Mode"
>
<.icon name="hero-sun" class="size-5 [[data-theme=dark]_&]:hidden" />
<.icon name="hero-moon" class="size-5 hidden [[data-theme=dark]_&]:block" />
</button>
"""
end
defp app_header(assigns) do
~H"""
<header class="sticky top-0 z-50 w-full border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md px-6 py-4">
<div class="mx-auto flex h-10 max-w-[1200px] items-center justify-between">
<.link
navigate="/"
class="flex items-center gap-4 text-slate-900 dark:text-slate-100 hover:opacity-80 transition-opacity"
>
<div class="flex items-center justify-center size-8 bg-sky-500 rounded-lg text-white shadow-lg shadow-sky-500/20">
<.icon name="hero-squares-2x2" class="size-5" />
</div>
<h2 class="text-lg font-bold leading-tight tracking-tight text-slate-800 dark:text-slate-100">
Spazio Solazzo
</h2>
</.link>
<div class="flex items-center gap-4">
<.theme_toggle />
<%= if @current_user do %>
<%!-- Desktop menu --%>
<div class="hidden md:flex items-center gap-3">
<.link
navigate={~p"/profile"}
class="size-10 rounded-full bg-slate-200 dark:bg-slate-700 flex items-center justify-center text-slate-500 dark:text-slate-400 border-2 border-primary/20 hover:border-primary/40 transition-colors"
>
<.icon name="hero-user" class="size-5" />
</.link>
<.link
href={~p"/sign-out"}
id="sign-out-link"
class="px-4 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-950/30 rounded-lg transition-colors border border-slate-300 dark:border-slate-600 hover:border-red-300 dark:hover:border-red-800"
>
Sign Out
</.link>
</div>
<%!-- Mobile menu button --%>
<button
phx-click={JS.toggle(to: "#mobile-menu")}
class="md:hidden p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
id="mobile-menu-button"
>
<.icon name="hero-bars-3" class="size-6 text-slate-600 dark:text-slate-400" />
</button>
<% else %>
<.link
navigate={~p"/sign-in"}
id="sign-in-link"
class="px-4 py-2 text-sm font-medium text-white bg-sky-500 hover:bg-sky-600 rounded-lg transition-colors shadow-sm"
>
Sign In
</.link>
<% end %>
</div>
</div>
<%!-- Mobile dropdown menu --%>
<%= if @current_user do %>
<div
id="mobile-menu"
class="md:hidden absolute top-full right-0 left-0 mt-2 mx-6 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-lg overflow-hidden"
style="display: none;"
>
<div class="flex flex-col">
<.link
navigate={~p"/profile"}
phx-click={JS.hide(to: "#mobile-menu")}
class="flex items-center gap-3 px-4 py-3 text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
>
<.icon name="hero-user" class="size-5 text-slate-500 dark:text-slate-400" /> Profile
</.link>
<.link
href={~p"/sign-out"}
id="mobile-sign-out-link"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium text-red-600 dark:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors border-t border-slate-200 dark:border-slate-800"
>
<.icon name="hero-arrow-right-on-rectangle" class="size-5" /> Sign Out
</.link>
</div>
</div>
<% end %>
</header>
"""
end
defp footer(assigns) do
current_year = Date.utc_today().year
assigns = assign(assigns, :current_year, current_year)
~H"""
<footer class="border-t border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 py-12 px-6 transition-colors duration-300">
<div class="mx-auto max-w-[1200px] flex flex-col md:flex-row justify-between gap-8">
<div class="flex flex-col gap-4 max-w-sm">
<div class="flex items-center gap-3 text-slate-900 dark:text-slate-100">
<div class="flex items-center justify-center size-6 bg-sky-500 rounded text-white">
<.icon name="hero-squares-2x2" class="size-4" />
</div>
<h2 class="text-base font-bold">Spazio Solazzo</h2>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">
A community-driven space dedicated to work, creativity, and connection.
</p>
</div>
<div class="flex gap-16 flex-wrap">
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-slate-100 uppercase tracking-wider mb-4">
Spaces
</h3>
<ul class="flex flex-col gap-3">
<li>
<a
href="/coworking"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Coworking
</a>
</li>
<li>
<a
href="/meeting"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Meeting Room
</a>
</li>
<li>
<a
href="/music"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Music Room
</a>
</li>
</ul>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-slate-100 uppercase tracking-wider mb-4">
Community
</h3>
<ul class="flex flex-col gap-3">
<li>
<a
href="https://caravanseraipalermo.it/"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Caravanserai Palermo
</a>
</li>
<li>
<a
href="https://mojocohouse.com/"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Mojo Cohouse
</a>
</li>
<li>
<a
href="https://jaster.xyz"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-slate-500 dark:text-slate-400 hover:text-sky-500 transition-colors"
>
Author's Blog
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="mx-auto max-w-[1200px] mt-12 pt-8 border-t border-slate-100 dark:border-slate-800 text-center md:text-left">
<p class="text-xs text-slate-500">
© {@current_year} Spazio Solazzo. All rights reserved.
</p>
</div>
</footer>
"""
end
end

View file

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="en">
<head>
{Application.get_env(:live_debugger, :live_debugger_tags)}
<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="SpazioSolazzo">
{assigns[:page_title]}
</.live_title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700;900&family=Montserrat:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<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) => {
localStorage.setItem("phx:theme", theme);
document.documentElement.setAttribute("data-theme", theme);
};
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "light");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "light"));
window.addEventListener("phx:set-theme", (e) => {
let theme = e.detail?.theme || e.target?.dataset?.phxTheme;
if (theme === "toggle") {
const currentTheme = localStorage.getItem("phx:theme") || "light";
theme = currentTheme === "light" ? "dark" : "light";
}
if (theme) setTheme(theme);
});
})();
</script>
</head>
<body class="flex flex-col min-h-screen">
{@inner_content}
</body>
</html>

View file

@ -0,0 +1,147 @@
defmodule SpazioSolazzoWeb.PageComponents do
@moduledoc """
Provides UI components specific to the page live view.
"""
use Phoenix.Component
import SpazioSolazzoWeb.CoreComponents
@doc """
Renders a space card component for displaying booking spaces.
## Examples
<.space_card
title="Coworking"
description="Flexible desk spaces for remote work"
price="15"
time_unit="4 hours"
image_url="https://..."
primary_label="Workspace"
image_position={:left}
booking_url="/coworking"
asset_type="Desk"
/>
<.space_card
title="Meeting Room"
description="Private conference rooms"
price="40"
time_unit="hour"
image_url="https://..."
primary_label="Business"
primary_label_variant={:secondary}
secondary_label="Up to 8 people"
secondary_label_icon="hero-user-group"
image_position={:right}
booking_url="/meeting"
asset_type="Room"
/>
"""
attr :title, :string, required: true
attr :description, :string, required: true
attr :price, :string, required: true
attr :time_unit, :string, required: true
attr :image_url, :string, required: true
attr :primary_label, :string, required: true
attr :primary_label_variant, :atom, default: :primary, values: [:primary, :secondary, :accent]
attr :secondary_label, :string, default: nil
attr :secondary_label_icon, :string, default: nil
attr :note, :string, default: nil
attr :image_position, :atom, default: :left, values: [:left, :right]
attr :booking_url, :string, required: true
attr :asset_type, :string, required: true
attr :id, :string, default: nil
def space_card(assigns) do
~H"""
<div
id={@id}
class="group relative overflow-hidden rounded-2xl bg-white dark:bg-slate-800 shadow-sm border border-slate-200 dark:border-slate-800 hover:border-sky-500/50 transition-all duration-300"
>
<div class={[
"flex flex-col h-full",
@image_position == :left && "md:flex-row",
@image_position == :right && "md:flex-row-reverse"
]}>
<div class="md:w-2/5 relative h-64 md:h-auto overflow-hidden">
<div
class="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={"background-image: url('#{@image_url}');"}
>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/60 to-transparent md:hidden">
</div>
<div class="absolute bottom-4 left-4 md:hidden">
<span class={[
"text-white text-xs font-bold px-2 py-1 rounded uppercase tracking-wider",
@primary_label_variant == :primary && "bg-sky-500",
@primary_label_variant == :secondary && "bg-slate-600",
@primary_label_variant == :accent && "bg-yellow-400 text-slate-900"
]}>
{@primary_label}
</span>
</div>
</div>
<div class="flex-1 p-6 md:p-8 flex flex-col justify-center">
<div class="flex items-center justify-between mb-2">
<span class={[
"hidden md:inline-block text-xs font-bold px-2 py-1 rounded uppercase tracking-wider mb-2",
@primary_label_variant == :primary &&
"text-sky-500 bg-sky-100 dark:bg-sky-900/20",
@primary_label_variant == :secondary &&
"text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800",
@primary_label_variant == :accent &&
"text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/20"
]}>
{@primary_label}
</span>
<%= if @secondary_label do %>
<div class="flex items-center gap-2 text-slate-500 dark:text-slate-400 text-sm">
<%= if @secondary_label_icon do %>
<.icon name={@secondary_label_icon} class="size-[18px]" />
<% end %>
<span>{@secondary_label}</span>
</div>
<% end %>
</div>
<h3 class="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-3">
{@title}
</h3>
<p class="text-slate-600 dark:text-slate-400 mb-6 leading-relaxed">
{@description}
<%= if @note do %>
<span class={[
"font-medium",
@primary_label_variant == :accent &&
"text-yellow-600 dark:text-yellow-400",
@primary_label_variant != :accent &&
"text-slate-700 dark:text-slate-300"
]}>
{@note}
</span>
<% end %>
</p>
<div class="flex flex-col sm:flex-row gap-4 sm:items-center justify-between mt-auto pt-6 border-t border-slate-100 dark:border-slate-800">
<div class="flex flex-col">
<span class="text-sm text-slate-500">Starting from</span>
<span class="text-lg font-bold text-slate-900 dark:text-slate-100">
{@price}
<span class="text-sm font-normal text-slate-500">/ {@time_unit}</span>
</span>
</div>
<.link
navigate={@booking_url}
class="h-10 px-6 bg-sky-500 hover:bg-sky-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2 group-hover:shadow-lg group-hover:shadow-sky-500/20"
>
<.icon name="hero-calendar" class="size-5" /> Book {@asset_type}
</.link>
</div>
</div>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,70 @@
defmodule SpazioSolazzoWeb.AuthController do
use SpazioSolazzoWeb, :controller
use AshAuthentication.Phoenix.Controller
alias SpazioSolazzo.Accounts
def magic_sign_in(conn, %{"token" => token, "remember_me" => remember_me} = args) do
name = Map.get(args, "name")
phone_number = Map.get(args, "phone_number")
result =
Accounts.sign_in_with_magic_link(
token,
remember_me == "true",
name,
phone_number,
authorize?: false
)
case result do
{:ok, user} ->
auth_success(conn, user)
{:error, _error} ->
auth_failure(conn)
end
end
defp auth_success(conn, user) do
return_to = get_session(conn, :return_to) || ~p"/"
remember_me = Ash.Resource.get_metadata(user, :remember_me)
conn =
case remember_me do
nil ->
conn
%{max_age: max_age, token: token} ->
put_resp_cookie(conn, "remember_me", token,
http_only: true,
secure: true,
same_site: "lax",
max_age: max_age
)
end
conn
|> delete_session(:return_to)
|> store_in_session(user)
|> assign(:current_user, user)
|> put_flash(:info, "You are now signed in")
|> redirect(to: return_to)
end
defp auth_failure(conn) do
conn
|> put_flash(:error, "Authentication failed. Please try again.")
|> redirect(to: ~p"/sign-in")
end
def sign_out(conn, _params) do
conn
|> clear_session(:spazio_solazzo)
|> AshAuthentication.Strategy.RememberMe.Plug.Helpers.delete_all_remember_me_cookies(
:spazio_solazzo
)
|> put_flash(:info, "You are now signed out")
|> redirect(to: ~p"/")
end
end

View file

@ -0,0 +1,77 @@
defmodule SpazioSolazzoWeb.BookingController do
use SpazioSolazzoWeb, :controller
alias SpazioSolazzo.BookingSystem
alias SpazioSolazzo.BookingSystem.Booking
alias SpazioSolazzo.BookingSystem.Booking.Token
def confirm(conn, %{"token" => token}) do
case Token.verify(token) do
{:ok, %{booking_id: booking_id, role: :admin, action: :confirm}} ->
case Ash.get(Booking, booking_id, error?: false) do
{:ok, nil} ->
conn
|> put_flash(:error, "Booking not found, cancelling aborted.")
|> redirect(to: "/")
{:ok, booking} ->
action_result = BookingSystem.confirm_booking(booking)
build_response(conn, action_result, :confirm)
{:error, _} ->
conn
|> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
|> redirect(to: "/")
end
_ ->
conn
|> put_flash(:error, "Invalid or expired link.")
|> redirect(to: "/")
end
end
def cancel(conn, %{"token" => token}) do
case Token.verify(token) do
{:ok, %{booking_id: booking_id, role: _, action: :cancel}} ->
case Ash.get(Booking, booking_id, error?: false) do
{:ok, nil} ->
conn
|> put_flash(:error, "Booking not found, cancelling aborted.")
|> redirect(to: "/")
{:ok, booking} ->
action_result = BookingSystem.cancel_booking(booking)
build_response(conn, action_result, :cancel)
{:error, _} ->
conn
|> put_flash(:error, "Unexpected error occurred, couldn't cancel booking.")
|> redirect(to: "/")
end
_ ->
conn
|> put_flash(:error, "Invalid or expired link.")
|> redirect(to: "/")
end
end
defp build_response(conn, action_result, action_name) do
case action_result do
{:ok, _booking} ->
conn
|> put_flash(:info, success_message(action_name))
|> redirect(to: "/")
{:error, _} ->
conn
|> put_flash(:error, "Action could not be completed (e.g. already processed).")
|> redirect(to: "/")
end
end
defp success_message(:cancel), do: "The booking has been cancelled."
defp success_message(:confirm), do: "The booking has been confirmed."
defp success_message(_), do: "Action completed successfully."
end

View file

@ -0,0 +1,24 @@
defmodule SpazioSolazzoWeb.ErrorHTML do
@moduledoc """
This module is invoked by your endpoint in case of errors on HTML requests.
See config/config.exs.
"""
use SpazioSolazzoWeb, :html
# If you want to customize your error pages,
# uncomment the embed_templates/1 call below
# and add pages to the error directory:
#
# * lib/spazio_solazzo_web/controllers/error_html/404.html.heex
# * lib/spazio_solazzo_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 SpazioSolazzoWeb.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,39 @@
<h1 class="text-orange">🔔 New Booking Received</h1>
<div style="margin-bottom: 20px;">
<p><strong>Customer:</strong> {@customer_name}</p>
<p><strong>Email:</strong> <a href={"mailto:#{@customer_email}"}>{@customer_email}</a></p>
<p><strong>Phone:</strong> <a href={"tel:#{@customer_phone}"}>{@customer_phone}</a></p>
</div>
<.details_list>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
</.details_list>
<div style="margin-top: 25px; margin-bottom: 25px;">
<h3 style="color: #2d3748; font-size: 16px; margin-bottom: 8px;">Customer Comment:</h3>
<%= if @customer_comment && String.trim(@customer_comment) != "" do %>
<div style="background-color: #fffaf0; border-left: 4px solid #ed8936; padding: 15px; color: #2d3748; font-size: 16px; font-weight: 500;">
“{@customer_comment}”
</div>
<% else %>
<div style="background-color: #f7fafc; padding: 12px; border-radius: 4px; color: #718096; font-style: italic; border: 1px dashed #cbd5e0;">
No additional comments provided.
</div>
<% end %>
</div>
<hr class="divider" />
<h3 style="color: #5C6BC0; text-align: center;">Admin Actions</h3>
<p class="text-center">Please confirm arrival or cancel the booking.</p>
<.email_button href={@confirm_url} variant={:primary}>
Confirm Arrival
</.email_button>
<.email_button href={@cancel_url} variant={:danger}>
Cancel Booking
</.email_button>

View file

@ -0,0 +1,37 @@
<h1 class="text-orange">🎉 Booking Confirmed!</h1>
<p>Hello <strong><%= @customer_name %></strong>,</p>
<p>Thank you for choosing Spazio Solazzo! Your booking has been successfully confirmed.</p>
<.details_list>
<.detail_item label="Date">{@date}</.detail_item>
<.detail_item label="Time">{@start_time} - {@end_time}</.detail_item>
<.detail_item label="Email">{@customer_email}</.detail_item>
<.detail_item label="Phone">{@customer_phone}</.detail_item>
<.detail_item label="Note">{@customer_comment || "N/A"}</.detail_item>
</.details_list>
<p class="text-center">
If you need to manage or cancel this booking, please use the link below:
</p>
<.email_button href={@cancel_url} variant={:danger}>
Cancel Booking
</.email_button>
<hr class="divider" />
<div style="background-color: #f8fafc; border-radius: 8px; padding: 20px; text-align: center; margin-top: 30px;">
<h3 style="color: #2d3748; margin-top: 0;">Need Help?</h3>
<p style="color: #4a5568; font-size: 14px; margin-bottom: 15px;">
Do you have questions or need to update your booking details? <br />
Our Front Office is available to assist you at any time.
</p>
<a
href={"tel:#{@front_office_phone_number}"}
style="display: inline-block; background-color: #edf2f7; color: #2d3748; padding: 10px 20px; border-radius: 50px; text-decoration: none; font-weight: bold; font-size: 18px; border: 1px solid #cbd5e0;"
>
📞 {@front_office_phone_number}
</a>
</div>

View file

@ -0,0 +1,54 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{@email.subject}</title>
<style>
/* Base & Typography */
body { margin: 0; padding: 0; background-color: #f4f4f4; font-family: 'Segoe UI', Arial, sans-serif; }
p { margin: 0 0 15px 0; padding: 0; color: #333333; line-height: 1.6; font-size: 15px; }
/* Container Component Style */
.container {
max-width: 600px;
margin: 40px auto;
background-color: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
border: 1px solid #e0e0e0;
padding: 40px;
overflow: hidden;
}
/* Headers */
h1 { margin: 0 0 25px 0; text-align: center; font-size: 24px; }
.text-orange { color: #FF7043; }
.text-purple { color: #673AB7; }
/* Components: Details List */
.details-list { list-style: none; padding-left: 0; border-left: 4px solid #FFCCBC; padding-left: 20px; margin: 20px 0; }
.details-list li { margin-bottom: 8px; color: #555; }
/* Components: Buttons */
.btn-wrapper { text-align: center; margin: 25px 0; }
.btn { padding: 12px 30px; text-decoration: none; border-radius: 6px; display: inline-block; font-weight: bold; font-size: 16px; color: #ffffff !important; }
.btn-primary { background-color: #FFB300; border: 1px solid #F57C00; }
.btn-danger { background-color: #EF5350; border: 1px solid #D32F2F; }
/* Components: Code Box */
.code-box { text-align: center; background-color: #F3E5F5; padding: 25px; border-radius: 8px; margin: 25px 0; border: 1px dashed #D1C4E9; }
.code-text { font-size: 32px; letter-spacing: 8px; font-family: monospace; color: #9C27B0; font-weight: bold; margin: 10px 0 0 0; }
/* Utilities */
.text-center { text-align: center; }
.text-sm { font-size: 13px; color: #888; }
.divider { border: 0; height: 1px; background-color: #f0f0f0; margin: 30px 0; }
</style>
</head>
<body>
<.email_container>
{@inner_content}
</.email_container>
</body>
</html>

View file

@ -0,0 +1,74 @@
defmodule SpazioSolazzoWeb.EmailView do
use SpazioSolazzoWeb, :html
embed_templates "email_templates/*"
def render(template, assigns) when is_binary(template) do
template
|> Path.rootname()
|> String.to_atom()
|> then(fn name ->
if function_exported?(__MODULE__, name, 1) do
apply(__MODULE__, name, [assigns])
else
raise "template #{template} not implemented in #{__MODULE__}"
end
end)
end
@doc """
Renders the main container for the email.
"""
slot :inner_block, required: true
def email_container(assigns) do
~H"""
<div class="container">
{render_slot(@inner_block)}
</div>
"""
end
@doc """
Renders a primary or secondary action button.
accepts: :primary (orange) or :danger (red)
"""
attr :href, :string, required: true
attr :variant, :atom, default: :primary, values: [:primary, :danger]
slot :inner_block, required: true
def email_button(assigns) do
~H"""
<div class="btn-wrapper">
<.link href={@href} class={["btn", "btn-#{@variant}"]} target="_blank">
{render_slot(@inner_block)}
</.link>
</div>
"""
end
@doc """
Renders a styled list for booking details.
"""
slot :inner_block, required: true
def details_list(assigns) do
~H"""
<ul class="details-list">
{render_slot(@inner_block)}
</ul>
"""
end
@doc """
Renders a single detail item.
"""
attr :label, :string, required: true
slot :inner_block, required: true
def detail_item(assigns) do
~H"""
<li><strong>{@label}:</strong> {render_slot(@inner_block)}</li>
"""
end
end

View file

@ -0,0 +1,60 @@
defmodule SpazioSolazzoWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :spazio_solazzo
# 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: "_spazio_solazzo_key",
signing_salt: "T68a6zB6",
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: :spazio_solazzo,
gzip: not code_reloading?,
only: SpazioSolazzoWeb.static_paths(),
raise_on_missing_only: code_reloading?
if Code.ensure_loaded?(Tidewave) do
plug Tidewave
end
# 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 AshPhoenix.Plug.CheckCodegenStatus
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :spazio_solazzo
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 SpazioSolazzoWeb.Router
end

View file

@ -0,0 +1,25 @@
defmodule SpazioSolazzoWeb.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: SpazioSolazzoWeb.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: :spazio_solazzo
end

View file

@ -0,0 +1,94 @@
defmodule SpazioSolazzoWeb.AuthCallbackLive do
@moduledoc """
Handles magic link callbacks for authentication.
Shows registration form for new users, sign-in confirmation for existing users.
"""
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.Accounts
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:email, nil)
|> assign(:existing_user?, false)
|> assign(:token, nil)
{:ok, socket}
end
@impl true
def handle_params(params, _uri, socket) do
token = params["token"]
socket =
case token do
nil ->
socket
|> put_flash(:error, "Missing token parameter")
|> push_navigate(to: ~p"/sign-in")
token ->
process_magic_link(socket, token)
end
{:noreply, socket}
end
defp process_magic_link(socket, token) do
case extract_email_from_token(token) do
{:ok, email} ->
existing_user? =
case Accounts.get_user_by_email(email, authorize?: false) do
{:ok, user} when not is_nil(user) -> true
_ -> false
end
socket
|> assign(:token, token)
|> assign(:email, email)
|> assign(:existing_user?, existing_user?)
{:error, _reason} ->
socket
|> put_flash(:error, "Invalid or expired magic link")
|> push_navigate(to: ~p"/sign-in")
end
end
@impl true
def handle_event("sign_in", args, %{assigns: %{token: token}} = socket) do
remember_me = Map.get(args, "remember_me") == "on"
{:noreply,
redirect(socket, to: ~p"/auth/magic/sign-in?token=#{token}&remember_me=#{remember_me}")}
end
@impl true
def handle_event(
"register",
%{"name" => name, "phone_number" => phone_number} = args,
socket
) do
%{token: token} = socket.assigns
remember_me = Map.get(args, "remember_me") == "on"
{:noreply,
redirect(socket,
to:
~p"/auth/magic/sign-in?token=#{token}&name=#{name}&phone_number=#{phone_number}&remember_me=#{remember_me}"
)}
end
defp extract_email_from_token(token) do
case AshAuthentication.Jwt.peek(token) do
{:ok, %{"identity" => email}} ->
{:ok, email}
{:error, _} = error ->
error
end
end
end

View file

@ -0,0 +1,159 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-sky-50 to-slate-100 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-8">
<%= if @existing_user? do %>
<%!-- Existing User Sign In --%>
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center size-12 bg-sky-100 dark:bg-sky-900/30 rounded-full mb-4">
<.icon name="hero-check-circle" class="size-6 text-sky-600 dark:text-sky-400" />
</div>
<h2 class="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Welcome Back!
</h2>
<p class="text-slate-600 dark:text-slate-400 text-sm">
We found your account. Click below to continue.
</p>
</div>
<form id="sign-in-form" phx-submit="sign_in" class="space-y-5">
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-envelope" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@email}
</span>
</div>
<label class="flex items-start gap-3 cursor-pointer group p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors">
<input
type="checkbox"
name="remember_me"
id="remember_me"
class="mt-0.5 size-5 rounded border-slate-300 dark:border-slate-600 text-sky-600 focus:ring-2 focus:ring-sky-500 focus:ring-offset-0 dark:bg-slate-700 cursor-pointer"
/>
<span class="text-sm text-slate-700 dark:text-slate-300 leading-tight">
Remember me on this device for 30 days
</span>
</label>
<button
type="submit"
id="sign-in-button"
class="w-full px-6 py-3.5 bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 text-white font-semibold rounded-xl transition-all shadow-lg shadow-sky-500/30 hover:shadow-xl hover:shadow-sky-500/40 hover:-translate-y-0.5 active:translate-y-0"
>
Sign In to Your Account
</button>
</form>
<% else %>
<%!-- New User Registration --%>
<div class="text-center mb-6">
<div class="inline-flex items-center justify-center size-12 bg-sky-100 dark:bg-sky-900/30 rounded-full mb-4">
<.icon name="hero-user-plus" class="size-6 text-sky-600 dark:text-sky-400" />
</div>
<h2 class="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">
Complete Your Profile
</h2>
<p class="text-slate-600 dark:text-slate-400 text-sm">
Just a few details to get you started
</p>
</div>
<form id="registration-form" phx-submit="register" class="space-y-5">
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-envelope" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@email}
</span>
</div>
<div>
<label
for="name"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Full Name <span class="text-rose-500">*</span>
</label>
<div class="relative">
<input
type="text"
name="name"
id="name"
required
class="w-full pl-11 pr-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-shadow"
placeholder="John Doe"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<.icon name="hero-user" class="size-5 text-slate-400 dark:text-slate-500" />
</div>
</div>
</div>
<div>
<label
for="phone_number"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Phone Number <span class="text-rose-500">*</span>
</label>
<div class="relative">
<input
type="tel"
name="phone_number"
id="phone_number"
required
class="w-full pl-11 pr-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-shadow"
placeholder="+39 123 456 7890"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<.icon name="hero-phone" class="size-5 text-slate-400 dark:text-slate-500" />
</div>
</div>
</div>
<label class="flex items-start gap-3 cursor-pointer group p-3 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-900/50 transition-colors">
<input
type="checkbox"
name="remember_me"
id="remember_me"
class="mt-0.5 size-5 rounded border-slate-300 dark:border-slate-600 text-sky-600 focus:ring-2 focus:ring-sky-500 focus:ring-offset-0 dark:bg-slate-700 cursor-pointer"
/>
<span class="text-sm text-slate-700 dark:text-slate-300 leading-tight">
Remember me on this device for 30 days
</span>
</label>
<button
type="submit"
id="register-button"
class="w-full px-6 py-3.5 bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 text-white font-semibold rounded-xl transition-all shadow-lg shadow-sky-500/30 hover:shadow-xl hover:shadow-sky-500/40 hover:-translate-y-0.5 active:translate-y-0"
>
Create Account
</button>
</form>
<% end %>
</div>
<div class="px-8 py-4 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-200 dark:border-slate-700">
<p class="text-xs text-center text-slate-500 dark:text-slate-400">
By continuing, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
<div class="mt-6 text-center">
<.link
navigate="/"
class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-sky-600 dark:hover:text-sky-400 transition-colors"
>
<.icon name="hero-arrow-left" class="size-4" />
<span>Back to home</span>
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -0,0 +1,43 @@
defmodule SpazioSolazzoWeb.SignInLive do
@moduledoc """
Simple LiveView for requesting magic link sign-in emails.
"""
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.Accounts
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:email, "")
|> assign(:loading, false)
{:ok, socket}
end
@impl true
def handle_event("request_magic_link", %{"email" => email}, socket) do
socket = assign(socket, :loading, true)
result = Accounts.request_magic_link(email, authorize?: false)
case result do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Check your email for a sign-in link!")
|> assign(:loading, false)
|> assign(:email, "")}
{:error, _error} ->
# Note: Magic link strategy usually returns :ok even if email is missing
# to prevent user enumeration, but we handle the error case for safety.
{:noreply,
socket
|> put_flash(:error, "Something went wrong. Please try again.")
|> assign(:loading, false)}
end
end
end

View file

@ -0,0 +1,68 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="min-h-screen bg-gradient-to-br from-slate-50 via-sky-50 to-slate-100 dark:from-slate-950 dark:via-slate-900 dark:to-slate-950 flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h2 class="text-slate-600 dark:text-slate-400 text-2xl">
Sign in with your email
</h2>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="p-8">
<form phx-submit="request_magic_link" class="space-y-5">
<div>
<label
for="email"
class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"
>
Email Address
</label>
<div class="relative">
<input
type="email"
name="email"
id="email"
value={@email}
required
class="w-full pl-11 pr-4 py-3 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-shadow"
placeholder="you@example.com"
/>
<div class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none">
<.icon name="hero-envelope" class="size-5 text-slate-400 dark:text-slate-500" />
</div>
</div>
</div>
<button
type="submit"
disabled={@loading}
class="w-full px-6 py-3.5 bg-gradient-to-r from-sky-500 to-sky-600 hover:from-sky-600 hover:to-sky-700 disabled:from-slate-400 disabled:to-slate-500 text-white font-semibold rounded-xl transition-all shadow-lg shadow-sky-500/30 hover:shadow-xl hover:shadow-sky-500/40 hover:-translate-y-0.5 active:translate-y-0 disabled:cursor-not-allowed disabled:transform-none"
>
<%= if @loading do %>
Sending...
<% else %>
Request Magic Link
<% end %>
</button>
</form>
</div>
<div class="px-8 py-4 bg-slate-50 dark:bg-slate-900/50 border-t border-slate-200 dark:border-slate-700">
<p class="text-xs text-center text-slate-500 dark:text-slate-400">
We'll send you a secure link to sign in without a password
</p>
</div>
</div>
<div class="mt-6 text-center">
<.link
navigate="/"
class="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 hover:text-sky-600 dark:hover:text-sky-400 transition-colors"
>
<.icon name="hero-arrow-left" class="size-4" />
<span>Back to home</span>
</.link>
</div>
</div>
</div>
</Layouts.app>

View file

@ -0,0 +1,130 @@
defmodule SpazioSolazzoWeb.AssetBookingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.BookingComponents
require Ash.Query
def mount(%{"asset_id" => asset_id}, _session, socket) do
case BookingSystem.get_asset_by_id(asset_id, load: [:space]) do
{:ok, asset} ->
selected_date = Date.utc_today()
{:ok, time_slots} =
BookingSystem.get_space_time_slots_by_date(asset.space.id, selected_date)
{:ok, bookings} =
BookingSystem.list_active_asset_bookings_by_date(asset.id, selected_date)
if connected?(socket) do
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:created")
Phoenix.PubSub.subscribe(SpazioSolazzo.PubSub, "booking:cancelled")
end
{:ok,
socket
|> assign(
asset: asset,
space: asset.space,
bookings: bookings,
selected_date: selected_date,
selected_time_slot: nil,
show_booking_modal: false,
show_success_modal: false,
time_slots: time_slots
)}
{:error, _error} ->
{:ok,
socket
|> put_flash(:error, "Asset not found")
|> push_navigate(to: "/")}
end
end
def handle_event("select_slot", %{"time_slot_id" => time_slot_id}, socket) do
time_slot = Enum.find(socket.assigns.time_slots, &(&1.id == time_slot_id))
{:noreply, assign(socket, selected_time_slot: time_slot, show_booking_modal: true)}
end
def handle_event("cancel_booking", _params, socket) do
{:noreply, assign(socket, show_booking_modal: false)}
end
def handle_event("close_success_modal", _params, socket) do
{:noreply, assign(socket, show_success_modal: false)}
end
def handle_info({:create_booking, comment}, socket) do
current_user = socket.assigns.current_user
result =
BookingSystem.create_booking(
socket.assigns.selected_time_slot.id,
socket.assigns.asset.id,
current_user.id,
socket.assigns.selected_date,
current_user.name,
current_user.email,
current_user.phone_number,
comment
)
case result do
{:ok, _booking} ->
{:noreply,
socket
|> assign(
show_booking_modal: false,
show_success_modal: true
)}
{:error, _error} ->
{:noreply,
socket
|> assign(show_booking_modal: false)
|> put_flash(:error, "Failed to create booking.}")}
end
end
def handle_info(
%{topic: "booking:created", payload: %{data: %{asset_id: asset_id, date: date}}},
%{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
) do
{:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
{:noreply, assign(socket, bookings: bookings)}
end
def handle_info(
%{topic: "booking:cancelled", payload: %{data: %{asset_id: asset_id, date: date}}},
%{assigns: %{asset: %{id: asset_id}, selected_date: date}} = socket
) do
{:ok, bookings} = BookingSystem.list_active_asset_bookings_by_date(asset_id, date)
{:noreply, assign(socket, bookings: bookings)}
end
def handle_info({:date_selected, date}, socket) do
{:ok, time_slots} =
BookingSystem.get_space_time_slots_by_date(socket.assigns.space.id, date)
{:ok, bookings} =
BookingSystem.list_active_asset_bookings_by_date(socket.assigns.asset.id, date)
{:noreply,
assign(socket,
selected_date: date,
time_slots: time_slots,
bookings: bookings
)}
end
def handle_info(_msg, socket) do
{:noreply, socket}
end
defp slot_booked?(time_slot_id, bookings) do
Enum.any?(bookings, fn booking ->
booking.time_slot_template_id == time_slot_id
end)
end
end

View file

@ -0,0 +1,80 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<section class="mx-auto max-w-[1200px] px-6 py-10">
<div class="mb-10">
<.link
navigate={"/#{@space.slug}"}
class="inline-flex items-center gap-2 text-sm font-medium text-slate-500 hover:text-sky-500 dark:text-slate-400 dark:hover:text-white transition-colors"
>
<.icon name="hero-arrow-left" class="w-5 h-5" /> Back to {@space.name}
</.link>
</div>
<div class="text-center mb-12">
<h1 class="text-4xl md:text-5xl lg:text-6xl font-black text-slate-900 dark:text-white tracking-tight mb-4">
{@asset.name}
</h1>
<p class="text-lg text-slate-600 dark:text-slate-400 max-w-2xl mx-auto">
{@space.name} - Flexible booking options available
</p>
</div>
<div class="max-w-4xl mx-auto bg-white dark:bg-slate-800 rounded-3xl p-8 md:p-12 border border-slate-200 dark:border-slate-700 shadow-xl shadow-slate-200/50 dark:shadow-none">
<h2 class="text-2xl font-bold text-slate-900 dark:text-white mb-8">
Available Time Slots
</h2>
<div class="mb-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
<.live_component
module={SpazioSolazzoWeb.CalendarLiveComponent}
id="booking-calendar"
selected_date={@selected_date}
/>
<div class="time-slots-wrapper">
<p class="mb-4 text-slate-500 dark:text-slate-400">
Selected day:
<span class="font-bold text-slate-900 dark:text-white">
{SpazioSolazzo.CalendarExt.format_date(@selected_date)}
</span>
</p>
<div class="max-h-80 overflow-y-auto pr-4 space-y-3">
<%= if @time_slots == [] do %>
<div class="text-center py-8 text-slate-500 dark:text-slate-400">
No time slots available for this date
</div>
<% else %>
<%= for time_slot <- @time_slots do %>
<% booked = slot_booked?(time_slot.id, @bookings) %>
<.time_slot booked={booked} time_slot={time_slot} />
<% end %>
<% end %>
</div>
</div>
</div>
<div class="mt-8 pt-6 border-t border-slate-200 dark:border-slate-700 text-center">
<p class="text-base font-medium text-sky-500 dark:text-sky-400 flex items-center justify-center gap-2">
<.icon name="hero-credit-card" class="w-5 h-5" /> Payment due upon arrival.
</p>
</div>
</div>
</section>
<.live_component
module={SpazioSolazzoWeb.BookingFormLiveComponent}
id="booking-modal"
show={@show_booking_modal}
selected_time_slot={@selected_time_slot}
asset={@asset}
selected_date={@selected_date}
current_user={@current_user}
on_cancel={JS.push("cancel_booking")}
/>
<.booking_confirmation_modal
id="success-modal"
show={@show_success_modal}
on_close={JS.push("close_success_modal")}
/>
</Layouts.app>

View file

@ -0,0 +1,127 @@
defmodule SpazioSolazzoWeb.BookingFormLiveComponent do
@moduledoc """
A live component that collects customer information for completing a booking.
"""
use SpazioSolazzoWeb, :live_component
alias SpazioSolazzo.CalendarExt
def update(assigns, socket) do
initial_data = %{
"customer_comment" => ""
}
form = assigns[:form] || to_form(initial_data)
{:ok,
socket
|> assign(assigns)
|> assign(:form, form)}
end
def handle_event("validate_form", params, socket) do
{:noreply, assign(socket, form: to_form(params))}
end
def handle_event("submit_booking", params, socket) do
comment = params["customer_comment"] || ""
send(self(), {:create_booking, comment})
{:noreply, socket}
end
def render(assigns) do
~H"""
<div>
<.modal :if={@show} id={@id} show on_cancel={@on_cancel}>
<:title>Complete Your Booking</:title>
<:subtitle>
<%= if @selected_time_slot do %>
{@asset.name} | {CalendarExt.format_time_range(@selected_time_slot)} on {CalendarExt.format_date(
@selected_date
)}
<% end %>
</:subtitle>
<div>
<.form
for={@form}
id="booking-form"
phx-submit="submit_booking"
phx-change="validate_form"
phx-target={@myself}
>
<div class="mt-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Name
</label>
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-user" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.name}
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Email
</label>
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-envelope" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.email}
</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Phone
</label>
<div class="flex items-center gap-3 p-4 bg-gradient-to-r from-slate-50 to-sky-50/50 dark:from-slate-900 dark:to-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
<div class="flex-shrink-0">
<.icon name="hero-phone" class="size-5 text-sky-600 dark:text-sky-400" />
</div>
<span class="text-sm font-medium text-slate-700 dark:text-slate-300 truncate">
{@current_user.phone_number}
</span>
</div>
</div>
<.input
field={@form[:customer_comment]}
type="textarea"
label="Comments (Optional)"
placeholder="Any special requests or notes..."
rows="4"
/>
</div>
<div class="mt-6 flex items-center gap-3">
<button
type="submit"
class="flex-1 bg-teal-600 hover:bg-teal-700 text-white font-semibold py-3 px-4 rounded-2xl transition-colors"
>
Confirm
</button>
<button
type="button"
phx-click={@on_cancel}
class="flex-1 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 font-semibold py-3 px-4 rounded-2xl transition-colors"
>
Cancel
</button>
</div>
</.form>
</div>
</.modal>
</div>
"""
end
end

View file

@ -0,0 +1,138 @@
defmodule SpazioSolazzoWeb.CalendarLiveComponent do
@moduledoc """
LiveView component for rendering booking calendars.
"""
use SpazioSolazzoWeb, :live_component
# There are 7 days displayed in the calendar
@grid_cols 7
# The calendar can show max 6 weeks for one month
@grid_rows 6
def update(assigns, socket) do
# Initialize navigation date to today's month if not already viewing a month
beginning_of_month =
socket.assigns[:beginning_of_month] ||
Date.utc_today()
|> Date.beginning_of_month()
selected_date = assigns[:selected_date] || Date.utc_today()
{:ok,
socket
|> assign(assigns)
|> assign(:beginning_of_month, beginning_of_month)
|> assign(:selected_date, selected_date)
|> assign(:today, Date.utc_today())
|> assign_calendar_grid()}
end
def handle_event("prev-month", _params, socket) do
new_beginning_of_month =
socket.assigns.beginning_of_month
|> Date.shift(month: -1)
|> Date.beginning_of_month()
{:noreply,
socket
|> assign(:beginning_of_month, new_beginning_of_month)
|> assign_calendar_grid()}
end
def handle_event("next-month", _params, socket) do
new_beginning_of_month =
socket.assigns.beginning_of_month
|> Date.shift(month: 1)
|> Date.beginning_of_month()
{:noreply,
socket
|> assign(:beginning_of_month, new_beginning_of_month)
|> assign_calendar_grid()}
end
def handle_event("select-date", %{"date" => date_str}, socket) do
date = Date.from_iso8601!(date_str)
send(self(), {:date_selected, date})
{:noreply, assign(socket, :selected_date, date)}
end
defp assign_calendar_grid(socket) do
first = socket.assigns.beginning_of_month
# Calculate offset to start grid on Monday (Monday = 1)
day_of_week = Date.day_of_week(socket.assigns.beginning_of_month)
days_before = day_of_week - 1
start_date = Date.add(first, -days_before)
grid = Enum.map(0..(@grid_cols * @grid_rows - 1), fn n -> Date.add(start_date, n) end)
assign(socket, :grid, grid)
end
def render(assigns) do
~H"""
<div id={@id} class="calendar-container">
<div class="flex items-center justify-between mb-4">
<button
type="button"
phx-click="prev-month"
phx-target={@myself}
class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400 transition-colors"
>
<.icon name="hero-chevron-left" class="w-5 h-5" />
</button>
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
{Calendar.strftime(@beginning_of_month, "%B %Y")}
</h3>
<button
type="button"
phx-click="next-month"
phx-target={@myself}
class="p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400 transition-colors"
>
<.icon name="hero-chevron-right" class="w-5 h-5" />
</button>
</div>
<div class="grid grid-cols-7 text-center text-sm font-medium text-slate-500 dark:text-slate-400 mb-2">
<span>Mo</span><span>Tu</span><span>We</span><span>Th</span><span>Fr</span><span>Sa</span><span>Su</span>
</div>
<div class="grid grid-cols-7 gap-y-2 text-center text-slate-700 dark:text-slate-300">
<%= for date <- @grid do %>
<% is_selected = Date.compare(date, @selected_date) == :eq
is_past = Date.compare(date, @today) == :lt
is_beginning_of_month = date.month == @beginning_of_month.month %>
<%= if is_beginning_of_month do %>
<button
type="button"
phx-click={!is_past && "select-date"}
phx-value-date={Date.to_iso8601(date)}
phx-target={@myself}
disabled={is_past}
class={
[
"p-2 rounded-full transition-colors",
# Styling for past dates (disabled)
is_past && "cursor-not-allowed opacity-40 text-slate-400 dark:text-slate-600",
# Styling for selected date
is_selected &&
"bg-sky-500 text-white font-bold shadow-md shadow-sky-500/30",
# Styling for regular dates
!is_past && !is_selected &&
"hover:bg-sky-500/20 dark:hover:bg-sky-500/20"
]
}
>
{date.day}
</button>
<% else %>
<div class="p-2"></div>
<% end %>
<% end %>
</div>
</div>
"""
end
end

View file

@ -0,0 +1,67 @@
defmodule SpazioSolazzoWeb.CarouselLiveComponent do
@moduledoc """
A LiveComponent for image carousels with navigation controls.
"""
use Phoenix.LiveComponent
import SpazioSolazzoWeb.CoreComponents, only: [icon: 1]
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:carousel_index, fn -> 0 end)}
end
@impl true
def handle_event("carousel_next", _params, socket) do
images_count = length(socket.assigns.images)
new_index = rem(socket.assigns.carousel_index + 1, images_count)
{:noreply, assign(socket, carousel_index: new_index)}
end
@impl true
def handle_event("carousel_prev", _params, socket) do
images_count = length(socket.assigns.images)
new_index = rem(socket.assigns.carousel_index - 1 + images_count, images_count)
{:noreply, assign(socket, carousel_index: new_index)}
end
@impl true
def render(assigns) do
~H"""
<div class="relative w-full h-full">
<div
class="flex h-full transition-transform duration-500 ease-in-out"
style={"transform: translateX(-#{@carousel_index * 100}%);"}
>
<div
:for={image <- @images}
class="w-full flex-shrink-0 bg-cover bg-center"
style={"background-image: url('#{image}');"}
>
</div>
</div>
<%= if length(@images) > 1 do %>
<button
phx-click="carousel_prev"
phx-target={@myself}
aria-label="Previous image"
class="absolute z-999 left-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 backdrop-blur-sm p-2 rounded-full text-white transition-colors cursor-pointer"
>
<.icon name="hero-chevron-left" class="w-6 h-6" />
</button>
<button
phx-click="carousel_next"
phx-target={@myself}
aria-label="Next image"
class="absolute z-999 right-4 top-1/2 -translate-y-1/2 bg-white/20 hover:bg-white/40 backdrop-blur-sm p-2 rounded-full text-white transition-colors cursor-pointer"
>
<.icon name="hero-chevron-right" class="w-6 h-6" />
</button>
<% end %>
</div>
"""
end
end

View file

@ -0,0 +1,25 @@
defmodule SpazioSolazzoWeb.CoworkingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("coworking")
{:ok, assets} = BookingSystem.get_space_assets(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]
{:ok,
socket
|> assign(
space: space,
assets: assets,
images: images
)}
end
end

View file

@ -0,0 +1,83 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path="#interactive-floor-plan"
booking_label="Explore Desks & Book"
price="€25"
price_unit="day"
capacity="5 Desks"
images={@images}
>
<:title>{@space.name}</:title>
<:description>
Join a vibrant community of innovators in the heart of Palermo. A flexible, open-plan sanctuary designed for focus and connection.
</:description>
</.page_header>
<.features_section
title="Everything for the modern nomad"
description="We've curated the perfect environment for productivity, ensuring you have the tools to thrive in Sicily."
>
<:feature
icon="hero-wifi"
title="Fiber Internet"
description="Dedicated 1Gbps symmetrical fiber line ensuring you never drop a call or buffer a video."
color="emerald"
>
</:feature>
<:feature
icon="hero-home"
title="Ergonomic Comfort"
description="Herman Miller chairs and spacious desks designed to keep you comfortable all day."
color="indigo"
/>
<:feature
icon="hero-cake"
title="Community Kitchen"
description="Unlimited freshly brewed Sicilian coffee, herbal teas, and a space to share meals."
color="purple"
/>
</.features_section>
<section class="py-20 px-6 bg-white dark:bg-slate-900" id="interactive-floor-plan">
<div class="mx-auto max-w-[1200px]">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-4">
Interactive Floor Plan
</h2>
<p class="text-slate-600 dark:text-slate-400 max-w-lg mx-auto">
Select any desk to customize your booking details on the next page, where availability is confirmed.
</p>
</div>
<div class="bg-slate-50 dark:bg-slate-800/50 rounded-3xl p-8 md:p-12 border border-slate-200 dark:border-slate-800 shadow-xl shadow-slate-200/50 dark:shadow-none relative overflow-hidden">
<div class="absolute top-6 right-6 opacity-10 pointer-events-none select-none">
<.icon name="hero-building-office-2" class="w-32 h-32 text-sky-500" />
</div>
<div class="flex flex-col items-center gap-10">
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 w-full max-w-3xl">
<.link
:for={asset <- @assets}
navigate={~p"/book/asset/#{asset.id}"}
class="group relative flex flex-col items-center gap-3 cursor-pointer"
>
<div class="w-full aspect-[4/3] rounded-xl bg-white dark:bg-slate-800 border-2 border-slate-200 dark:border-slate-700 group-hover:border-sky-500 dark:group-hover:border-teal-500 group-hover:shadow-lg group-hover:shadow-sky-500/20 dark:group-hover:shadow-teal-500/20 transition-all duration-300 flex items-center justify-center relative">
<.icon
name="hero-computer-desktop"
class="w-12 h-12 text-slate-300 group-hover:text-sky-500 dark:group-hover:text-teal-500 transition-colors"
/>
</div>
<span class="text-sm font-bold text-slate-500 group-hover:text-sky-500 dark:group-hover:text-teal-500 transition-colors">
{asset.name}
</span>
</.link>
</div>
</div>
</div>
</div>
</section>
<.house_rules title="Coworking House Rules">
<:rule>Please keep phone calls to the dedicated booths.</:rule>
<:rule>Clean your desk area before leaving.</:rule>
<:rule>Be respectful of quiet zones.</:rule>
</.house_rules>
</Layouts.app>

View file

@ -0,0 +1,25 @@
defmodule SpazioSolazzoWeb.MeetingLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("meeting")
{:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCh5O9cz1ruQFH0Pq3MzC_1HsWrLPHbWlfYEdB2dmPi0YDn2L23R5hseUZmb19XlEju1n4a24oD6pH5qiG4SvIemrD45PfKwvNlckpOG59IYz5WYrHzroq7L4Uq9Hxl0PTzU5m8R5k625w_MrdZKidyfM6OnzNJfM5J3XftFI5A9J7wD_BDHRKxq8gxAukUCesuYX8lGm3AhQAZQTjaUY5yeobjt-NCSrlfTzxmcUmibJSTnKZuwx-li4QtFr0wQrzHVLUZYiAhA251",
"https://lh3.googleusercontent.com/aida-public/AB6AXuCanfiWzXqH3fBrE6U3phirIFZo5bgKG1aa8wnXCRC12yOXkcgnGUTRhxppIk61QUdQWF9KuFAtjhDEI9AACV-pM7yXyPKbOKognCARD-qbffFtCwGLidcLkoprLnNAW12C7TeRL6gOEBas3RI7jCf30JmzMmSqCjMx3lixgrOr6qlpbHZA4Eog_P41y5zXtn9Nqlq2eB6c7RYhiOIJzXVpMmfLR_qf0HTmOnx2poDbqKcLDcCM-p4S6aAwLxC-GYBmvEfWQ4meToCL"
]
{:ok,
socket
|> assign(
space: space,
asset: asset,
images: images
)}
end
end

View file

@ -0,0 +1,62 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path={~p"/book/asset/#{@asset.id}"}
price="€40"
price_unit="hour"
capacity="Up to 8 People"
images={@images}
>
<:title>{@space.name}</:title>
<:description>
A private, sun-drenched sanctuary designed for focus and collaboration. Step into a space where Sicilian charm meets modern productivity.
</:description>
</.page_header>
<.features_section
title="Everything you need to succeed"
description="We've equipped the Meeting Room with top-tier amenities so you can focus on the agenda, not the logistics."
>
<:feature
icon="hero-tv"
title="4K Presentation"
description="Crystal clear 65&quot; monitor ready for your slide decks. Connect via HDMI or wireless casting."
color="sky"
/>
<:feature
icon="hero-video-camera"
title="Video Conferencing"
description="Logitech Rally bar with AI framing and noise cancellation for seamless remote meetings."
color="orange"
/>
<:feature
icon="hero-pencil"
title="Creative Tools"
description="Wall-to-wall glass whiteboard, sticky notes, and markers to capture every brainstorming session."
color="yellow"
/>
<:feature
icon="hero-wifi"
title="Fiber Internet"
description="Dedicated 1Gbps symmetrical fiber line ensuring you never drop a call or buffer a video."
color="emerald"
/>
<:feature
icon="hero-home"
title="Ergonomic Comfort"
description="Herman Miller chairs and a solid oak table designed to keep you comfortable during long sessions."
color="indigo"
/>
<:feature
icon="hero-cake"
title="Catering Available"
description="Pre-order coffee carafes, Sicilian pastries, or light lunch options from local partners."
color="purple"
/>
</.features_section>
<.house_rules title="House Rules">
<:rule>Please clean the whiteboard after use.</:rule>
<:rule>Outside food is allowed, but please be tidy.</:rule>
<:rule>Cancel up to 24 hours before for a full refund.</:rule>
</.house_rules>
</Layouts.app>

View file

@ -0,0 +1,26 @@
defmodule SpazioSolazzoWeb.MusicLive do
use SpazioSolazzoWeb, :live_view
alias SpazioSolazzo.BookingSystem
import SpazioSolazzoWeb.LandingComponents
def mount(_params, _session, socket) do
{:ok, space} = BookingSystem.get_space_by_slug("music")
{:ok, asset} = BookingSystem.get_asset_by_space_id(space.id)
images = [
"https://lh3.googleusercontent.com/aida-public/AB6AXuD1wkxK48dk7i5XYX6JL-O1egrsdLjcmOg7N4EB76QtUvhzR7lZQadprIT9rLPsroUjazftFRpp_z26wb8lHaUW9XyucGlKG3qG40oT5iaKWwqVI1drNKJDJgVBkmNjw4u_D5vig_C1pf6bgGZnPaOV2tnnmlexxHJIDQYZzfg1GGwgywBvpGLz_u2_jvkbyMo3_m5roM09PjonFEfGIHxjm0vClW1DAOX45IrT87A85OdAXEu2EPyB8oW9WzmolOn4DFj22vKWSbVD",
"https://lh3.googleusercontent.com/aida-public/AB6AXuB3fJu4mgZaw8GP1OC2SjquJZJmnRlY_OHD4fO4AAd_KHd5BYnW1i0egrskoEfK_uCdK4pQu5kMf8pF9h_KXE0wYQAROnTBTJ4YmBpHui9nv8wz44VENo2p-lA3rW8xhQhiYzlAhHJlhgOdZluVp9eYvsZxGM76QkDXMcBQz1Ka5ZfRMNgddo1RS76IPaxbQIvpOh_55uW87bAiGAvhcE8GrIi2ugpiJ64Rdou1uZLD1bPWxUvyLtpTFFLr2vfVjq7OpVYiGnLaGstS",
"https://lh3.googleusercontent.com/aida-public/AB6AXuBVY6j_kvSUC0trHVnwvszpxZa58CpY0sGTF6m6lPQJkFlN-GnK1ofNaSn8PU1JnmPPAl7B196LoYq4SfawlDFrg2ADKKr65cOE0jq2L9w-cXrkPxE4poylrIeKX8zP1JsIS5obvU5_HAG074RjeYSWFsV5Z7wQF0ktZlYL6m462hsl-xdLQWQiBLZOHNsBf6jrZieUst9dKUlec6hzWOqcbIXuugBJW5fklJmMti9CDQynn1XZ5I5gZYEL47tW2Ku9u_zEEpfqmEKF"
]
{:ok,
socket
|> assign(
space: space,
asset: asset,
images: images
)}
end
end

View file

@ -0,0 +1,62 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<.page_header
booking_path={~p"/book/asset/#{@asset.id}"}
price="€25"
price_unit="hour"
capacity="Up to 5 People"
images={@images}
>
<:title>{@space.name}</:title>
<:description>
A relaxed, creative space for jamming, practice sessions, or just unwinding with instruments. A casual vibe, not a soundproof studio, perfect for connecting through music.
</:description>
</.page_header>
<.features_section
title="Jam, Practice, Create"
description="The Music Room is equipped with essentials for a good session. It's not a pro studio, but it has soul and everything you need to start playing."
>
<:feature
icon="hero-musical-note"
title="House Instruments"
description="Includes a digital piano, acoustic guitar, and a basic drum kit ready for your use."
color="sky"
/>
<:feature
icon="hero-speaker-wave"
title="PA System"
description="Two active speakers and a simple 4-channel mixer to plug in vocals or keyboards."
color="orange"
/>
<:feature
icon="hero-home"
title="Relaxed Vibe"
description="Comfortable seating, warm lighting, and rugs creates a cozy atmosphere for creative flow."
color="yellow"
/>
<:feature
icon="hero-microphone"
title="Basic Mics"
description="Two Shure SM58 microphones with stands available for vocals or acoustic instruments."
color="emerald"
/>
<:feature
icon="hero-speaker-x-mark"
title="Not Soundproof"
description="Please note this is a community space. Some sound bleed occurs; keep volumes reasonable."
color="indigo"
/>
<:feature
icon="hero-musical-note"
title="Bring Your Gear"
description="Feel free to bring your own amps, pedals, or specific instruments to dial in your tone."
color="purple"
/>
</.features_section>
<.house_rules title="Jam Session Rules">
<:rule>Reset instruments to their original places.</:rule>
<:rule>No drinks on top of the piano or amps.</:rule>
<:rule>Be mindful of volume for our coworking neighbors.</:rule>
</.house_rules>
</Layouts.app>

View file

@ -0,0 +1,19 @@
defmodule SpazioSolazzoWeb.PageLive do
use SpazioSolazzoWeb, :live_view
import SpazioSolazzoWeb.PageComponents
alias SpazioSolazzo.BookingSystem
def mount(_params, _session, socket) do
{:ok, coworking_space} = BookingSystem.get_space_by_slug("coworking")
{:ok, meeting_space} = BookingSystem.get_space_by_slug("meeting")
{:ok, music_space} = BookingSystem.get_space_by_slug("music")
{:ok,
assign(socket,
coworking_space: coworking_space,
meeting_space: meeting_space,
music_space: music_space
)}
end
end

View file

@ -0,0 +1,107 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="relative flex min-h-screen flex-col">
<section class="relative">
<div class="mx-auto max-w-[1200px] px-6 py-8 md:py-12">
<div class="relative overflow-hidden rounded-2xl bg-slate-800 min-h-[560px] flex flex-col items-center justify-center text-center p-8 md:p-16 isolate shadow-2xl shadow-black/20">
<div
class="absolute inset-0 z-[-1] bg-cover bg-center"
style="background-image: linear-gradient(rgba(15, 23, 42, 0.5) 0%, rgba(15, 23, 42, 0.9) 100%), url('https://lh3.googleusercontent.com/aida-public/AB6AXuBVz7hjSfL7xWWBlBXpSrRPuvuZmh5BA_08oQ4rFz40B3LzNT_5mEPpp7Rijgny5L9PuA2WdReFGkAVHYKwAooKRsKVNl2Z0Af7sEtbJexf3vy_ztOz6aQVY48nllXQtVOzowyihYcrTZWJkV4ZT6m6gTCzMovPPQsv-_Lk1UTwGUED4kHXQgnZQuvEUcEprvAcBTCpPHRq7cn3QVldkdAlDfVpoQUQfKMlfuAJHyrHHlFkGf5NlRiH0q-l8QAHCQ8T16DiZi5R3j9I');"
>
</div>
<div class="flex flex-col items-center gap-6 max-w-3xl">
<h1 class="text-white text-5xl md:text-7xl font-black leading-tight tracking-tight">
Creativity
<span class="text-transparent bg-clip-text bg-gradient-to-r from-yellow-300 to-sky-400">
lives here
</span>
</h1>
<p class="text-slate-300 text-lg md:text-xl font-normal max-w-2xl leading-relaxed">
Work, Meet, and Jam in the heart of the city. Join a grounded community of innovators at Spazio Solazzo.
</p>
<div class="flex flex-col sm:flex-row gap-4 mt-4">
<a
href="#our-spaces"
class="h-12 px-8 rounded-xl bg-sky-500 hover:bg-sky-600 text-white font-bold transition-all shadow-lg shadow-sky-500/30 flex items-center gap-2"
>
Explore Spaces <.icon name="hero-arrow-down" class="size-5" />
</a>
</div>
</div>
</div>
</div>
</section>
<div class="mx-auto max-w-[1200px] px-6 pt-10 pb-6" id="our-spaces">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4 border-b border-slate-200 dark:border-slate-800 pb-6">
<div>
<h2 class="text-slate-900 dark:text-slate-100 text-3xl font-bold tracking-tight">
Our Spaces
</h2>
<p class="text-slate-500 dark:text-slate-400 mt-2">
Choose the perfect environment for your needs.
</p>
</div>
<div class="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400 bg-slate-100 dark:bg-slate-800 px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-800">
<.icon name="hero-information-circle" class="size-[18px]" />
<span>Payment due in person upon arrival</span>
</div>
</div>
</div>
<section class="mx-auto max-w-[1200px] px-6 pb-20 space-y-8" id="spaces">
<%= if @coworking_space do %>
<.space_card
id="coworking"
title={@coworking_space.name}
description={@coworking_space.description}
price="15"
time_unit="4 hours"
image_url="https://lh3.googleusercontent.com/aida-public/AB6AXuApty1_MYrfkL2mpGOAKKvlxo-7B-Y6nnko3DA4UDhJ-dCSjcyOLMFy1C0xZmh1_Pu9TEEFj25GkJ74dR1Sb_x4sY9mDjecKICFvwHFgHkMFVMigsZjldl9rH34x4tZhdpWvUGCo32V1P5_0uwtXVPohKMbvIbBxr5nsPoEy_d7X4WgIMOA1Nv2bwDgkqbsG4X3noBx-riLqcnREEl3cb0kbtquJZJ6pYHfbimuNyuxtfQHzrG8KOMHe3YPoIgWt43mgPtgPL9gswni"
primary_label="Workspace"
primary_label_variant={:primary}
image_position={:left}
booking_url={"/#{@coworking_space.slug}"}
asset_type="Desk"
/>
<% end %>
<%= if @meeting_space do %>
<.space_card
id="meeting"
title={@meeting_space.name}
description={@meeting_space.description}
price="40"
time_unit="hour"
image_url="https://lh3.googleusercontent.com/aida-public/AB6AXuDmh_AkVuUoICqpHk1NdLuLdi0xQBOC8Hy9PrsSNz956igHFRhbNGsB8k0vSLe2U2NW1sxRVZm_dwR27Q4Db_f21XbYkLtfiRYob-j4ran1rTBB0bQAz4QLFSO1yL_cPhDIpAyvC069mDQ33-ckZgZ_yvFsIK_-_0Jj2NEOnDie684uaR7vKuiBWlsr-JmAsPzUp7Aik7Qbzozune348nBz1bvWkBNMCpMO3JV8hrYBo1i6JlUiGSuP3-5fWXKt8dKhxPUN-amjLFgh"
primary_label="Business"
primary_label_variant={:secondary}
secondary_label="Up to 8 people"
secondary_label_icon="hero-user-group"
image_position={:right}
booking_url={"/#{@meeting_space.slug}"}
asset_type="Room"
/>
<% end %>
<%= if @music_space do %>
<.space_card
id="music"
title={@music_space.name}
description={@music_space.description}
price="25"
time_unit="hour"
image_url="https://lh3.googleusercontent.com/aida-public/AB6AXuBBJs1jEAgwwiIvJD00kx3aA9pfI-o2fXT-eZ9dEQeHNHhvwQdVqrwsqwNvCR69rIYUNBKf-uY1dqXZSvXaXoE__slTLMqGHkUzSQSXql9PwhW3cLoMMv1wtj52qDORHy-0NE2_qbTLxm051aTxGLloQKUCIklZ0EMKxs8lvMpnLisnRZBkSMyUVcTBcQu16gZw3eDuMGUtXaTXskrQFGwDcThTCCF4TZiNAmgEk87ae3VgEwfJ2zBVeyHQ-BfMo5KHRtNl6lbzBT9N"
primary_label="Jam Space"
primary_label_variant={:accent}
secondary_label="Not Soundproof"
secondary_label_icon="hero-speaker-x-mark"
note="Note: This is not a professional studio and is not soundproofed."
image_position={:left}
booking_url={"/#{@music_space.slug}"}
asset_type="Slot"
/>
<% end %>
</section>
</div>
</Layouts.app>

View file

@ -0,0 +1,74 @@
defmodule SpazioSolazzoWeb.ProfileLive do
use SpazioSolazzoWeb, :live_view
alias AshPhoenix.Form
alias SpazioSolazzo.Accounts
def mount(_params, _session, socket) do
current_user = socket.assigns.current_user
profile_form =
Accounts.form_to_update_profile(current_user, actor: current_user)
|> to_form()
{:ok,
socket
|> assign(:profile_form, profile_form)
|> assign(:delete_history, false)
|> assign(:show_delete_modal, false)}
end
def handle_event("validate_profile", %{"form" => params}, socket) do
form = AshPhoenix.Form.validate(socket.assigns.profile_form, params)
{:noreply, assign(socket, :profile_form, form)}
end
def handle_event("save_profile", %{"form" => form_params}, socket) do
case Form.submit(socket.assigns.profile_form, params: form_params) do
{:ok, updated_user} ->
{:noreply,
socket
|> assign(:current_user, updated_user)
|> assign(:profile_form, to_form(form_params))
|> put_flash(:info, "Profile updated successfully")}
{:error, form} ->
{:noreply,
socket
|> assign(:profile_form, form)
|> put_flash(:error, "Something went wrong")}
end
end
def handle_event("toggle_delete_history", _params, socket) do
{:noreply, assign(socket, :delete_history, not socket.assigns.delete_history)}
end
def handle_event("show_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, true)}
end
def handle_event("hide_delete_modal", _params, socket) do
{:noreply, assign(socket, :show_delete_modal, false)}
end
def handle_event(
"confirm_delete_account",
_params,
%{assigns: %{current_user: current_user, delete_history: delete_history}} = socket
) do
case Accounts.terminate_account(current_user, delete_history, actor: current_user) do
:ok ->
{:noreply,
socket
|> put_flash(:info, "Your account has been deleted")
|> redirect(to: "/sign-out")}
{:error, _error} ->
{:noreply,
socket
|> assign(:show_delete_modal, false)
|> put_flash(:error, "Failed to delete account. Please try again.")}
end
end
end

View file

@ -0,0 +1,184 @@
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="mx-auto max-w-[800px] px-6 py-12">
<div class="mb-10 text-center">
<h1 class="text-4xl font-black text-slate-900 dark:text-white tracking-tight">
User Profile
</h1>
<p class="text-slate-500 dark:text-slate-400 mt-2">
Manage your account settings and personal information
</p>
</div>
<div class="space-y-8">
<%!-- Personal Information Section --%>
<section class="bg-white dark:bg-card-dark rounded-2xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm">
<div class="flex items-center gap-3 mb-8">
<.icon name="hero-user" class="text-primary w-6 h-6" />
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
Personal Information
</h2>
</div>
<.form
for={@profile_form}
id="profile-form"
phx-change="validate_profile"
phx-submit="save_profile"
>
<div class="grid gap-6">
<div class="space-y-2">
<.input
field={@profile_form[:name]}
type="text"
label="Full Name"
placeholder="Enter your full name"
class="w-full bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-semibold text-slate-700 dark:text-slate-300">
Email Address
</label>
<div class="relative">
<input
type="email"
readonly
value={@current_user.email}
title="Email cannot be changed"
class="w-full bg-slate-100 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3 text-slate-500 dark:text-slate-500 cursor-not-allowed outline-none"
/>
<.icon
name="hero-lock-closed"
class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400 w-5 h-5"
/>
</div>
<p class="text-xs text-slate-400">
Contact support to change your email address.
</p>
</div>
<div class="space-y-2">
<.input
field={@profile_form[:phone_number]}
type="tel"
label="Phone Number"
placeholder="+39"
class="w-full bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700 rounded-xl px-4 py-3 text-slate-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-all outline-none"
/>
</div>
</div>
<div class="mt-8 pt-8 border-t border-slate-100 dark:border-slate-800 flex justify-end">
<button
type="submit"
class="h-11 px-8 rounded-xl bg-primary hover:bg-primary-hover text-white font-bold transition-all shadow-lg shadow-sky-500/30 cursor-pointer"
>
Save Changes
</button>
</div>
</.form>
</section>
<%!-- Account Management Section --%>
<section class="bg-white dark:bg-card-dark rounded-2xl p-8 border border-slate-200 dark:border-slate-800 shadow-sm border-l-4 border-l-danger/50">
<div class="flex items-center gap-3 mb-8">
<.icon name="hero-no-symbol" class="text-danger w-6 h-6" />
<h2 class="text-xl font-bold text-slate-900 dark:text-white">
Account Management
</h2>
</div>
<div class="bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900/30 rounded-xl p-5 mb-8">
<h3 class="text-red-600 dark:text-red-500 font-bold text-sm mb-2 flex items-center gap-2">
<.icon
name="hero-exclamation-triangle"
class="w-5 h-5 text-red-600 dark:text-red-500"
/> Danger Zone
</h3>
<p class="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
Deleting your account is permanent. This action cannot be undone. All your current active bookings will be cancelled.
</p>
</div>
<div class="space-y-6">
<div class="flex items-start gap-3 p-4 bg-slate-50 dark:bg-slate-900/30 rounded-xl border border-slate-100 dark:border-slate-800">
<div class="flex h-6 items-center">
<input
type="checkbox"
id="gdpr-consent"
phx-click="toggle_delete_history"
value="on"
checked={@delete_history}
class="h-5 w-5 rounded border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-primary focus:ring-primary"
/>
</div>
<div class="text-sm leading-6">
<label for="gdpr-consent" class="font-medium text-slate-900 dark:text-slate-200">
Delete all my booking history and personal data (GDPR compliant)
</label>
<p class="text-slate-500 dark:text-slate-400">
We will permanently remove all records related to your identity and activities from our servers.
</p>
</div>
</div>
<div class="flex justify-start">
<button
type="button"
phx-click="show_delete_modal"
class="h-11 px-6 rounded-xl border-2 border-red-600 text-red-600 hover:bg-red-600 hover:text-white dark:border-red-500 dark:text-red-500 dark:hover:bg-red-500 font-bold transition-all flex items-center gap-2 cursor-pointer"
>
<.icon name="hero-trash" class="w-5 h-5" /> Delete My Account
</button>
</div>
</div>
</section>
</div>
</div>
<%!-- Delete Confirmation Modal --%>
<%= if @show_delete_modal do %>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
phx-click="hide_delete_modal"
>
<div class="bg-white dark:bg-card-dark rounded-2xl p-8 max-w-md mx-4 shadow-2xl border border-slate-200 dark:border-slate-800">
<div class="flex items-center gap-3 mb-4">
<.icon name="hero-exclamation-triangle" class="text-danger w-8 h-8" />
<h3 class="text-xl font-bold text-slate-900 dark:text-white">
Confirm Account Deletion
</h3>
</div>
<p class="text-slate-600 dark:text-slate-400 mb-6">
Are you absolutely sure you want to delete your account? This action cannot be undone.
<%= if @delete_history do %>
<strong class="text-danger">
All your booking history will be permanently deleted.
</strong>
<% else %>
Your booking history will be preserved but anonymized.
<% end %>
</p>
<div class="flex gap-4 justify-end">
<button
type="button"
phx-click="hide_delete_modal"
class="h-11 px-6 rounded-xl border-2 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 font-bold transition-all cursor-pointer"
>
Cancel
</button>
<button
type="button"
phx-click="confirm_delete_account"
class="h-11 px-6 rounded-xl bg-danger hover:bg-red-600 dark:text-white hover:text-white text-slate-700 font-bold transition-all cursor-pointer"
>
Yes, Delete My Account
</button>
</div>
</div>
</div>
<% end %>
</Layouts.app>

View file

@ -0,0 +1,39 @@
defmodule SpazioSolazzoWeb.LiveUserAuth do
@moduledoc """
Helpers for authenticating users in LiveViews.
"""
import Phoenix.Component
use SpazioSolazzoWeb, :verified_routes
# This is used for nested liveviews to fetch the current user.
# To use, place the following at the top of that liveview:
# on_mount {SpazioSolazzoWeb.LiveUserAuth, :current_user}
def on_mount(:current_user, _params, session, socket) do
{:cont, AshAuthentication.Phoenix.LiveSession.assign_new_resources(socket, session)}
end
def on_mount(:live_user_optional, _params, _session, socket) do
if socket.assigns[:current_user] do
{:cont, socket}
else
{:cont, assign(socket, :current_user, nil)}
end
end
def on_mount(:live_user_required, _params, _session, socket) do
if socket.assigns[:current_user] do
{:cont, socket}
else
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/sign-in")}
end
end
def on_mount(:live_no_user, _params, _session, socket) do
if socket.assigns[:current_user] do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
else
{:cont, assign(socket, :current_user, nil)}
end
end
end

View file

@ -0,0 +1,87 @@
defmodule SpazioSolazzoWeb.Router do
use SpazioSolazzoWeb, :router
use AshAuthentication.Phoenix.Router
import AshAuthentication.Plug.Helpers
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {SpazioSolazzoWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :sign_in_with_remember_me
plug :load_from_session
end
pipeline :api do
plug :accepts, ["json"]
plug :load_from_bearer
plug :set_actor, :user
end
scope "/", SpazioSolazzoWeb do
pipe_through :browser
get "/bookings/confirm", BookingController, :confirm
get "/bookings/cancel", BookingController, :cancel
get "/sign-out", AuthController, :sign_out
get "/auth/magic/sign-in", AuthController, :magic_sign_in
get "/auth/failure", AuthController, :auth_failure
ash_authentication_live_session :unauthenticated_routes,
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_user_optional}
] do
live "/", PageLive
live "/coworking", CoworkingLive
live "/meeting", MeetingLive
live "/music", MusicLive
end
ash_authentication_live_session :no_user_routes,
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_no_user}
] do
live "/sign-in/callback", AuthCallbackLive
live "/sign-in", SignInLive
end
ash_authentication_live_session :authenticated_routes,
on_mount: [
{SpazioSolazzoWeb.LiveUserAuth, :live_user_required}
] do
live "/book/asset/:asset_id", AssetBookingLive
live "/profile", ProfileLive
end
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:spazio_solazzo, :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: SpazioSolazzoWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
if Application.compile_env(:spazio_solazzo, :dev_routes) do
import AshAdmin.Router
scope "/admin" do
pipe_through :browser
ash_admin "/"
end
end
end

View file

@ -0,0 +1,93 @@
defmodule SpazioSolazzoWeb.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("spazio_solazzo.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("spazio_solazzo.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("spazio_solazzo.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("spazio_solazzo.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("spazio_solazzo.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.
# {SpazioSolazzoWeb, :count_users, []}
]
end
end

134
mix.exs Normal file
View file

@ -0,0 +1,134 @@
# Copyright 2026 Victor Martinez Montané
#
# Licensed under the Apache License, Version 2.0 (the "License"),
# subject to the Commons Clause License Condition v1.0.
# You may not use this file except in compliance with the License.
#
# See the LICENSE file for details.
#
defmodule SpazioSolazzo.MixProject do
use Mix.Project
def project do
[
app: :spazio_solazzo,
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],
consolidate_protocols: Mix.env() != :dev
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {SpazioSolazzo.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
[
{:ash, "~> 3.0"},
{:ash_admin, "~> 0.13"},
{:ash_authentication, "~> 4.0"},
{:ash_authentication_phoenix, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
{:ash_postgres, "~> 2.0"},
{:ash_state_machine, "~> 0.2"},
{:bandit, "~> 1.5"},
{:bcrypt_elixir, "~> 3.0"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
{:dns_cluster, "~> 0.2.0"},
{:ecto_sql, "~> 3.13"},
{:esbuild, "~> 0.10", runtime: Mix.env() == :dev},
{:gettext, "~> 1.0"},
{:heroicons,
github: "tailwindlabs/heroicons",
tag: "v2.2.0",
sparse: "optimized",
app: false,
compile: false,
depth: 1},
{:igniter, "~> 0.6", only: [:dev, :test]},
{:jason, "~> 1.2"},
{:lazy_html, ">= 0.1.0", only: :test},
{:live_debugger, "~> 0.5", only: [:dev]},
{:oban, "~> 2.18"},
{:phoenix, "~> 1.8.3"},
{:phoenix_ecto, "~> 4.5"},
{:phoenix_html, "~> 4.1"},
{:phoenix_live_dashboard, "~> 0.8.3"},
{:phoenix_live_reload, "~> 1.2", only: :dev},
{:phoenix_live_view, "~> 1.1.0"},
{:phoenix_swoosh, "~> 1.0"},
{:picosat_elixir, "~> 0.2"},
{:postgrex, ">= 0.0.0"},
{:req, "~> 0.5"},
{:resend, "~> 0.4.0"},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:swoosh, "~> 1.16"},
{:tailwind, "~> 0.3", runtime: Mix.env() == :dev},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:tidewave, "~> 0.5", only: [:dev]},
{:usage_rules, "~> 0.1", only: [:dev]}
]
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.drop",
"ash.setup",
"assets.setup",
"assets.build",
"run priv/repo/seeds.exs"
],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.drop", "ecto.create", "ecto.migrate", "test"],
"assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
"assets.build": ["compile", "tailwind spazio_solazzo", "esbuild spazio_solazzo"],
"assets.deploy": [
"tailwind spazio_solazzo --minify",
"esbuild spazio_solazzo --minify",
"phx.digest"
],
precommit: [
"compile --warnings-as-errors",
"deps.unlock --unused",
"format",
"test",
"credo"
]
]
end
end

98
mix.lock Normal file
View file

@ -0,0 +1,98 @@
%{
"ash": {:hex, :ash, "3.12.0", "5b78000df650d86b446d88977ef8aa5c9d9f7ffa1193fa3c4b901c60bff2d130", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7cf45b4eb83aa0ab5e6707d6e4ea4a10c29ab20613c87f06344f7953b2ca5e18"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.6", "95b17f0bfc00bd6e229145b90c7026f784ae81639e832de4b5c96a738de5ed46", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "27ded84bdc61fd267794dee17a6cbe6e52d0f365d3e8ea0460d95977b82ac6f1"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.14.1", "60d127a73c2144b39fa3dab045cc3f7fce0c3ccd2dd3e8534288f5da65f0c1db", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "3cd57aee855be3ccf2960ce0b005ad209c97fbfc81faa71212bcfbd6a4a90cae"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
"ash_postgres": {:hex, :ash_postgres, "2.6.27", "7aa119cc420909573a51802f414a49a9fb21a06ee78769efd7a4db040e748f5c", [:mix], [{:ash, ">= 3.11.2 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.16 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "f5e71dc3f77bc0c52374869df4b66493e13c0e27507c3d10ff13158ef7ea506f"},
"ash_sql": {:hex, :ash_sql, "0.3.16", "a4e62d2cf9b2f4a451067e5e3de28349a8d0e69cf50fc1861bad85f478ded046", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "f3d5a810b23e12e3e102799c68b1e934fa7f909ccaa4bd530f10c7317cfcfe56"},
"ash_state_machine": {:hex, :ash_state_machine, "0.2.12", "c0f7ebb8a176584f70c6ed196b7d0118c930d73e0590ade705d2dddc48aa7311", [:mix], [{:ash, ">= 3.4.66 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}], "hexpm", "394ce761ce82358e3c715e1cae6c5cf1390be27c03a8b661f2e5a2fda849873d"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.1", "6b1f8609d947ae2a74da5bba8aee938c94348634e54e5625eef622ca0bbbb062", [: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", "4b4c35f273030e44268ace53bf3d5991dfc385c77374244e2f960876547671aa"},
"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"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"},
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
"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.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [: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", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
"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"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"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, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
"glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"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"},
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"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"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"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"},
"oban": {:hex, :oban, "2.20.2", "f23313d83b578305cafa825a036cad84e7e2d61549ecbece3a2e6526d347cc3b", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.20", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "523365ef0217781c061d15f496e3200a5f1b43e08b1a27c34799ef8bfe95815f"},
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [: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", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
"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_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"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.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [: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", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.19", "c95e9acbc374fb796ee3e24bfecc8213123c74d9f9e45667ca40bb0a4d242953", [: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", "d5ad357d6b21562a5b431f0ad09dfe76db9ce5648c6949f1aac334c8c4455d32"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
"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"},
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
"picosat_elixir": {:hex, :picosat_elixir, "0.2.3", "bf326d0f179fbb3b706bb2c15fbc367dacfa2517157d090fdfc32edae004c597", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "f76c9db2dec9d2561ffaa9be35f65403d53e984e8cd99c832383b7ab78c16c66"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [: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", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"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"},
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [: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", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"resend": {:hex, :resend, "0.4.5", "a4a0701c590d1363e9c8e85457b9a143481d35225b0a5f558d9091b3e71d3589", [:mix], [{:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.3", [hex: :swoosh, repo: "hexpm", optional: false]}, {:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "1a340ba442febfe0443386393f7c086875f0656e7b41df3758deb869f1b99df1"},
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
"splode": {:hex, :splode, "0.2.10", "f755ebc8e5dc1556869c0513cf5f3450be602a41e01196249306483c4badbec0", [:mix], [], "hexpm", "906b6dc17b7ebc9b9fd9a31360bf0bd691d20e934fb28795c0ddb0c19d3198f1"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"swoosh": {:hex, :swoosh, "1.20.0", "b04134c2b302da74c3a95ca4ddde191e4854d2847d6687783fecb023a9647598", [: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", "13e610f709bae54851d68afb6862882aa646e5c974bf49e3bf5edd84a73cf213"},
"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"},
"tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"tidewave": {:hex, :tidewave, "0.5.4", "b7b6db62779a6faf139e630eb54f218cf3091ec5d39600197008db8474cb6fb2", [:mix], [{:bandit, ">= 1.10.1", [hex: :bandit, repo: "hexpm", optional: true]}, {:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "252c7cf4ffe81d4c5ad8ef709333e7124c5af554aa07dceab61135d0f205a898"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"usage_rules": {:hex, :usage_rules, "0.1.26", "19d38c8b9b5c35434eae44f7e4554caeb5f08037a1d45a6b059a9782543ac22e", [:mix], [{:igniter, ">= 0.6.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9f0d203aa288e1b48318929066778ec26fc423fd51f08518c5b47f58ad5caca9"},
"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"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
}

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,7 @@
defmodule SpazioSolazzo.Repo.Migrations.AddOban do
use Ecto.Migration
def up, do: Oban.Migration.up()
def down, do: Oban.Migration.down(version: 1)
end

View file

@ -0,0 +1,154 @@
defmodule SpazioSolazzo.Repo.Migrations.SetupResourcesExtensions1 do
@moduledoc """
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE($1, $2) $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS TRUE THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS NOT NULL THEN $2
ELSE $1
END $$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_trim_whitespace(arr text[])
RETURNS text[] AS $$
DECLARE
start_index INT = 1;
end_index INT = array_length(arr, 1);
BEGIN
WHILE start_index <= end_index AND arr[start_index] = '' LOOP
start_index := start_index + 1;
END LOOP;
WHILE end_index >= start_index AND arr[end_index] = '' LOOP
end_index := end_index - 1;
END LOOP;
IF start_index > end_index THEN
RETURN ARRAY[]::text[];
ELSE
RETURN arr[start_index : end_index];
END IF;
END; $$
LANGUAGE plpgsql
SET search_path = ''
IMMUTABLE;
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb)
RETURNS BOOLEAN AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION ash_raise_error(json_data jsonb, type_signal ANYCOMPATIBLE)
RETURNS ANYCOMPATIBLE AS $$
BEGIN
-- Raise an error with the provided JSON data.
-- The JSON object is converted to text for inclusion in the error message.
RAISE EXCEPTION 'ash_error: %', json_data::text;
RETURN NULL;
END;
$$ LANGUAGE plpgsql
STABLE
SET search_path = '';
""")
execute("""
CREATE OR REPLACE FUNCTION uuid_generate_v7()
RETURNS UUID
AS $$
DECLARE
timestamp TIMESTAMPTZ;
microseconds INT;
BEGIN
timestamp = clock_timestamp();
microseconds = (cast(extract(microseconds FROM timestamp)::INT - (floor(extract(milliseconds FROM timestamp))::INT * 1000) AS DOUBLE PRECISION) * 4.096)::INT;
RETURN encode(
set_byte(
set_byte(
overlay(uuid_send(gen_random_uuid()) placing substring(int8send(floor(extract(epoch FROM timestamp) * 1000)::BIGINT) FROM 3) FROM 1 FOR 6
),
6, (b'0111' || (microseconds >> 8)::bit(4))::bit(8)::int
),
7, microseconds::bit(8)::int
),
'hex')::UUID;
END
$$
LANGUAGE PLPGSQL
SET search_path = ''
VOLATILE;
""")
execute("""
CREATE OR REPLACE FUNCTION timestamp_from_uuid_v7(_uuid uuid)
RETURNS TIMESTAMP WITHOUT TIME ZONE
AS $$
SELECT to_timestamp(('x0000' || substr(_uuid::TEXT, 1, 8) || substr(_uuid::TEXT, 10, 4))::BIT(64)::BIGINT::NUMERIC / 1000);
$$
LANGUAGE SQL
SET search_path = ''
IMMUTABLE PARALLEL SAFE STRICT;
""")
execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
execute(
"DROP FUNCTION IF EXISTS uuid_generate_v7(), timestamp_from_uuid_v7(uuid), ash_raise_error(jsonb), ash_raise_error(jsonb, ANYCOMPATIBLE), ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE), ash_trim_whitespace(text[])"
)
# execute("DROP EXTENSION IF EXISTS \"citext\"")
end
end

View file

@ -0,0 +1,196 @@
defmodule SpazioSolazzo.Repo.Migrations.SetupResources do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
create table(:users, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :email, :citext, null: false
add :name, :text, null: false
add :phone_number, :text, null: false
end
create unique_index(:users, [:email], name: "users_unique_email_index")
create table(:tokens, primary_key: false) do
add :jti, :text, null: false, primary_key: true
add :subject, :text, null: false
add :expires_at, :utc_datetime, null: false
add :purpose, :text, null: false
add :extra_data, :map
add :created_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
end
create table(:time_slot_templates, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :start_time, :time, null: false
add :end_time, :time, null: false
add :day_of_week, :text, null: false
add :space_id, :uuid, null: false
end
create table(:spaces, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
end
alter table(:time_slot_templates) do
modify :space_id,
references(:spaces,
column: :id,
name: "time_slot_templates_space_id_fkey",
type: :uuid,
prefix: "public"
)
end
alter table(:spaces) do
add :name, :text, null: false
add :description, :text, null: false
add :slug, :text, null: false
end
create unique_index(:spaces, [:name], name: "spaces_unique_name_index")
create unique_index(:spaces, [:slug], name: "spaces_unique_slug_index")
create table(:bookings, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
add :date, :date, null: false
add :customer_name, :text, null: false
add :customer_email, :text, null: false
add :start_time, :time, null: false
add :end_time, :time, null: false
add :customer_phone, :text, null: false
add :customer_comment, :text
add :state, :text, null: false, default: "reserved"
add :inserted_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :updated_at, :utc_datetime_usec,
null: false,
default: fragment("(now() AT TIME ZONE 'utc')")
add :asset_id, :uuid
add :time_slot_template_id, :uuid
add :user_id, :uuid
end
create table(:assets, primary_key: false) do
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
end
alter table(:bookings) do
modify :asset_id,
references(:assets,
column: :id,
name: "bookings_asset_id_fkey",
type: :uuid,
prefix: "public"
)
modify :time_slot_template_id,
references(:time_slot_templates,
column: :id,
name: "bookings_time_slot_template_id_fkey",
type: :uuid,
prefix: "public"
)
modify :user_id,
references(:users,
column: :id,
name: "bookings_user_id_fkey",
type: :uuid,
prefix: "public",
on_delete: :nilify_all
)
end
create index(:bookings, [:user_id])
alter table(:assets) do
add :name, :text, null: false
add :space_id,
references(:spaces,
column: :id,
name: "assets_space_id_fkey",
type: :uuid,
prefix: "public"
), null: false
end
create unique_index(:assets, [:name, :space_id], name: "assets_unique_name_per_space_index")
end
def down do
drop_if_exists unique_index(:assets, [:name, :space_id],
name: "assets_unique_name_per_space_index"
)
drop constraint(:assets, "assets_space_id_fkey")
alter table(:assets) do
remove :space_id
remove :name
end
drop_if_exists index(:bookings, [:user_id])
drop constraint(:bookings, "bookings_asset_id_fkey")
drop constraint(:bookings, "bookings_time_slot_template_id_fkey")
drop constraint(:bookings, "bookings_user_id_fkey")
alter table(:bookings) do
modify :user_id, :uuid
modify :time_slot_template_id, :uuid
modify :asset_id, :uuid
end
drop table(:assets)
drop table(:bookings)
drop_if_exists unique_index(:spaces, [:slug], name: "spaces_unique_slug_index")
drop_if_exists unique_index(:spaces, [:name], name: "spaces_unique_name_index")
alter table(:spaces) do
remove :slug
remove :description
remove :name
end
drop constraint(:time_slot_templates, "time_slot_templates_space_id_fkey")
alter table(:time_slot_templates) do
modify :space_id, :uuid
end
drop table(:spaces)
drop table(:time_slot_templates)
drop table(:tokens)
drop_if_exists unique_index(:users, [:email], name: "users_unique_email_index")
drop table(:users)
end
end

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

@ -0,0 +1,107 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
alias SpazioSolazzo.BookingSystem
IO.puts("Seeding Spazio Solazzo booking system...")
# Check if database is already seeded
case BookingSystem.Space |> Ash.read() do
{:ok, [_ | _] = spaces} ->
IO.puts("✓ Database already seeded (found #{length(spaces)} spaces)")
IO.puts("✓ Run 'mix ecto.reset' to reset and re-seed the database")
System.halt(0)
_ ->
:ok
end
# Create Coworking Space
coworking =
BookingSystem.create_space!("Coworking", "coworking", "Flexible desk spaces for remote work")
IO.puts("✓ Created Coworking space")
# Create Meeting Room Space
meeting =
BookingSystem.create_space!("Meeting room", "meeting", "Private conference rooms by the hour")
IO.puts("✓ Created Meeting Room space")
# Create Music Studio Space
music = BookingSystem.create_space!("Music room", "music", "Evening recording sessions")
IO.puts("✓ Created Music Studio space")
# Create Coworking Tables (Assets)
tables =
for i <- 1..5 do
BookingSystem.create_asset!("Table #{i}", coworking.id)
end
IO.puts("✓ Created #{length(tables)} coworking tables")
# Create Meeting Room Asset
BookingSystem.create_asset!("Main Conference Room", meeting.id)
IO.puts("✓ Created meeting room asset")
# Create Music Studio Asset
BookingSystem.create_asset!("Recording Studio", music.id)
IO.puts("✓ Created music studio asset")
# Create Coworking Time Slot Templates for each weekday
coworking_slots = [
%{start_time: ~T[09:00:00], end_time: ~T[13:00:00]},
%{start_time: ~T[14:00:00], end_time: ~T[18:00:00]}
]
weekdays = [:monday, :tuesday, :wednesday, :thursday, :friday]
for day <- weekdays, slot <- coworking_slots do
BookingSystem.create_time_slot_template!(slot.start_time, slot.end_time, day, coworking.id)
end
IO.puts(
"✓ Created #{length(weekdays) * length(coworking_slots)} coworking time slots across weekdays"
)
# Create Meeting Room Hourly Slots (9am-6pm) for weekdays
meeting_slots =
for hour <- 9..17 do
start_time = Time.new!(hour, 0, 0)
end_time = Time.new!(hour + 1, 0, 0)
%{
start_time: start_time,
end_time: end_time
}
end
for day <- weekdays, slot <- meeting_slots do
BookingSystem.create_time_slot_template!(slot.start_time, slot.end_time, day, meeting.id)
end
IO.puts(
"✓ Created #{length(weekdays) * length(meeting_slots)} meeting room hourly slots across weekdays"
)
# Create Music Studio Evening Slots for all days of the week
music_slots = [
%{start_time: ~T[18:00:00], end_time: ~T[20:00:00]},
%{start_time: ~T[20:00:00], end_time: ~T[22:00:00]}
]
all_days = [:monday, :tuesday, :wednesday, :thursday, :friday, :saturday, :sunday]
for day <- all_days, slot <- music_slots do
BookingSystem.create_time_slot_template!(slot.start_time, slot.end_time, day, music.id)
end
IO.puts(
"✓ Created #{length(all_days) * length(music_slots)} music studio evening slots across all days"
)
IO.puts("\n🎉 Seeding complete!")

View file

@ -0,0 +1,93 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "assets_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "spaces"
},
"scale": null,
"size": null,
"source": "space_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "9FAD08E854AEBC8A2C7C7466BBC5254CAFB16C2A860034688C3C1142E183052C",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "assets_unique_name_per_space_index",
"keys": [
{
"type": "atom",
"value": "name"
},
{
"type": "atom",
"value": "space_id"
}
],
"name": "unique_name_per_space",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "assets"
}

View file

@ -0,0 +1,244 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "date",
"type": "date"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_email",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "start_time",
"type": "time"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "end_time",
"type": "time"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_phone",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "customer_comment",
"type": "text"
},
{
"allow_nil?": false,
"default": "\"reserved\"",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "state",
"type": "text"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_asset_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "assets"
},
"scale": null,
"size": null,
"source": "asset_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_time_slot_template_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "time_slot_templates"
},
"scale": null,
"size": null,
"source": "time_slot_template_id",
"type": "uuid"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": true,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "bookings_user_id_fkey",
"on_delete": "nilify",
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "users"
},
"scale": null,
"size": null,
"source": "user_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "D4E1D8A61AAAA83530EF07DE6BB15175AE052ADE62D06E538C222576218F0289",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "bookings"
}

View file

@ -0,0 +1,7 @@
{
"ash_functions_version": 5,
"installed": [
"ash-functions",
"citext"
]
}

View file

@ -0,0 +1,96 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "29965B10015A4BDE39A648F85CE4FDB39DDFC21E0CB18903C7F9677E11B11D21",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "spaces_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "spaces_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "spaces"
}

View file

@ -0,0 +1,98 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "start_time",
"type": "time"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "end_time",
"type": "time"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "day_of_week",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": {
"deferrable": false,
"destination_attribute": "id",
"destination_attribute_default": null,
"destination_attribute_generated": null,
"index?": false,
"match_type": null,
"match_with": null,
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"name": "time_slot_templates_space_id_fkey",
"on_delete": null,
"on_update": null,
"primary_key?": true,
"schema": "public",
"table": "spaces"
},
"scale": null,
"size": null,
"source": "space_id",
"type": "uuid"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F8562173C786461A330298CE68DE2EC79F3FD8380966F2F9E318E6010DEE466E",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "time_slot_templates"
}

View file

@ -0,0 +1,103 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "jti",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "subject",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "expires_at",
"type": "utc_datetime"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "purpose",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "extra_data",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "created_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "BEBC6DCB3A49C5C7A830CD684C51A1F901F7AB857C72B559F98A6D9ABAB6DB95",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "tokens"
}

View file

@ -0,0 +1,82 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "email",
"type": "citext"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "phone_number",
"type": "text"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "57A4F6BB98AF78EE399E42DAC6AAD95429A43D3A110B792DD6A05735591A5C62",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "users_unique_email_index",
"keys": [
{
"type": "atom",
"value": "email"
}
],
"name": "unique_email",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.SpazioSolazzo.Repo",
"schema": null,
"table": "users"
}

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

Some files were not shown because too many files have changed in this diff Show more