mirror of
https://codeberg.org/JasterV/spazio-solazzo.git
synced 2026-04-26 18:20:03 +00:00
Setup first public version of the project
This commit is contained in:
commit
8795de9ff7
120 changed files with 11708 additions and 0 deletions
218
.credo.exs
Normal file
218
.credo.exs
Normal 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
18
.formatter.exs
Normal 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
95
.github/workflows/ci.yml
vendored
Normal 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
37
.gitignore
vendored
Normal 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
10
.igniter.exs
Normal 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
373
AGENTS.md
Normal 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
373
CLAUDE.md
Normal 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
241
LICENSE.md
Normal 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
78
README.md
Normal 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
177
assets/css/app.css
Normal 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
83
assets/js/app.js
Normal 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
32
assets/tsconfig.json
Normal 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
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
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
43
assets/vendor/heroicons.js
vendored
Normal 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
138
assets/vendor/topbar.js
vendored
Normal 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
16
compose.yml
Normal 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
118
config/config.exs
Normal 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
100
config/dev.exs
Normal 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
31
config/prod.exs
Normal 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
135
config/runtime.exs
Normal 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
52
config/test.exs
Normal 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
17
lib/spazio_solazzo.ex
Normal 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
|
||||
25
lib/spazio_solazzo/accounts.ex
Normal file
25
lib/spazio_solazzo/accounts.ex
Normal 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
|
||||
118
lib/spazio_solazzo/accounts/token.ex
Normal file
118
lib/spazio_solazzo/accounts/token.ex
Normal 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
|
||||
160
lib/spazio_solazzo/accounts/user.ex
Normal file
160
lib/spazio_solazzo/accounts/user.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
36
lib/spazio_solazzo/application.ex
Normal file
36
lib/spazio_solazzo/application.ex
Normal 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
|
||||
55
lib/spazio_solazzo/booking_system.ex
Normal file
55
lib/spazio_solazzo/booking_system.ex
Normal 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
|
||||
43
lib/spazio_solazzo/booking_system/asset.ex
Normal file
43
lib/spazio_solazzo/booking_system/asset.ex
Normal 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
|
||||
192
lib/spazio_solazzo/booking_system/booking.ex
Normal file
192
lib/spazio_solazzo/booking_system/booking.ex
Normal 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
|
||||
91
lib/spazio_solazzo/booking_system/booking/email.ex
Normal file
91
lib/spazio_solazzo/booking_system/booking/email.ex
Normal 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
|
||||
49
lib/spazio_solazzo/booking_system/booking/email_worker.ex
Normal file
49
lib/spazio_solazzo/booking_system/booking/email_worker.ex
Normal 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
|
||||
30
lib/spazio_solazzo/booking_system/booking/token.ex
Normal file
30
lib/spazio_solazzo/booking_system/booking/token.ex
Normal 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
|
||||
31
lib/spazio_solazzo/booking_system/space.ex
Normal file
31
lib/spazio_solazzo/booking_system/space.ex
Normal 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
|
||||
58
lib/spazio_solazzo/booking_system/time_slot_template.ex
Normal file
58
lib/spazio_solazzo/booking_system/time_slot_template.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
16
lib/spazio_solazzo/data/calendar_ext.ex
Normal file
16
lib/spazio_solazzo/data/calendar_ext.ex
Normal 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
|
||||
3
lib/spazio_solazzo/mailer.ex
Normal file
3
lib/spazio_solazzo/mailer.ex
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
defmodule SpazioSolazzo.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :spazio_solazzo
|
||||
end
|
||||
22
lib/spazio_solazzo/repo.ex
Normal file
22
lib/spazio_solazzo/repo.ex
Normal 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
|
||||
16
lib/spazio_solazzo/secrets.ex
Normal file
16
lib/spazio_solazzo/secrets.ex
Normal 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
122
lib/spazio_solazzo_web.ex
Normal 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
|
||||
114
lib/spazio_solazzo_web/components/booking_components.ex
Normal file
114
lib/spazio_solazzo_web/components/booking_components.ex
Normal 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
|
||||
657
lib/spazio_solazzo_web/components/core_components.ex
Normal file
657
lib/spazio_solazzo_web/components/core_components.ex
Normal 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
|
||||
250
lib/spazio_solazzo_web/components/landing_components.ex
Normal file
250
lib/spazio_solazzo_web/components/landing_components.ex
Normal 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" 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
|
||||
303
lib/spazio_solazzo_web/components/layouts.ex
Normal file
303
lib/spazio_solazzo_web/components/layouts.ex
Normal 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
|
||||
48
lib/spazio_solazzo_web/components/layouts/root.html.heex
Normal file
48
lib/spazio_solazzo_web/components/layouts/root.html.heex
Normal 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>
|
||||
147
lib/spazio_solazzo_web/components/page_components.ex
Normal file
147
lib/spazio_solazzo_web/components/page_components.ex
Normal 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
|
||||
70
lib/spazio_solazzo_web/controllers/auth_controller.ex
Normal file
70
lib/spazio_solazzo_web/controllers/auth_controller.ex
Normal 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
|
||||
77
lib/spazio_solazzo_web/controllers/booking_controller.ex
Normal file
77
lib/spazio_solazzo_web/controllers/booking_controller.ex
Normal 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
|
||||
24
lib/spazio_solazzo_web/controllers/error_html.ex
Normal file
24
lib/spazio_solazzo_web/controllers/error_html.ex
Normal 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
|
||||
21
lib/spazio_solazzo_web/controllers/error_json.ex
Normal file
21
lib/spazio_solazzo_web/controllers/error_json.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
74
lib/spazio_solazzo_web/emails/email_view.ex
Normal file
74
lib/spazio_solazzo_web/emails/email_view.ex
Normal 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
|
||||
60
lib/spazio_solazzo_web/endpoint.ex
Normal file
60
lib/spazio_solazzo_web/endpoint.ex
Normal 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
|
||||
25
lib/spazio_solazzo_web/gettext.ex
Normal file
25
lib/spazio_solazzo_web/gettext.ex
Normal 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
|
||||
94
lib/spazio_solazzo_web/live/auth/auth_callback_live.ex
Normal file
94
lib/spazio_solazzo_web/live/auth/auth_callback_live.ex
Normal 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
|
||||
159
lib/spazio_solazzo_web/live/auth/auth_callback_live.html.heex
Normal file
159
lib/spazio_solazzo_web/live/auth/auth_callback_live.html.heex
Normal 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>
|
||||
43
lib/spazio_solazzo_web/live/auth/sign_in_live.ex
Normal file
43
lib/spazio_solazzo_web/live/auth/sign_in_live.ex
Normal 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
|
||||
68
lib/spazio_solazzo_web/live/auth/sign_in_live.html.heex
Normal file
68
lib/spazio_solazzo_web/live/auth/sign_in_live.html.heex
Normal 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>
|
||||
130
lib/spazio_solazzo_web/live/booking/asset_booking_live.ex
Normal file
130
lib/spazio_solazzo_web/live/booking/asset_booking_live.ex
Normal 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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
138
lib/spazio_solazzo_web/live/booking/calendar_live_component.ex
Normal file
138
lib/spazio_solazzo_web/live/booking/calendar_live_component.ex
Normal 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
|
||||
|
|
@ -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
|
||||
25
lib/spazio_solazzo_web/live/landing/coworking_live.ex
Normal file
25
lib/spazio_solazzo_web/live/landing/coworking_live.ex
Normal 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
|
||||
83
lib/spazio_solazzo_web/live/landing/coworking_live.html.heex
Normal file
83
lib/spazio_solazzo_web/live/landing/coworking_live.html.heex
Normal 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>
|
||||
25
lib/spazio_solazzo_web/live/landing/meeting_live.ex
Normal file
25
lib/spazio_solazzo_web/live/landing/meeting_live.ex
Normal 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
|
||||
62
lib/spazio_solazzo_web/live/landing/meeting_live.html.heex
Normal file
62
lib/spazio_solazzo_web/live/landing/meeting_live.html.heex
Normal 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" 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>
|
||||
26
lib/spazio_solazzo_web/live/landing/music_live.ex
Normal file
26
lib/spazio_solazzo_web/live/landing/music_live.ex
Normal 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
|
||||
62
lib/spazio_solazzo_web/live/landing/music_live.html.heex
Normal file
62
lib/spazio_solazzo_web/live/landing/music_live.html.heex
Normal 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>
|
||||
19
lib/spazio_solazzo_web/live/page_live.ex
Normal file
19
lib/spazio_solazzo_web/live/page_live.ex
Normal 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
|
||||
107
lib/spazio_solazzo_web/live/page_live.html.heex
Normal file
107
lib/spazio_solazzo_web/live/page_live.html.heex
Normal 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>
|
||||
74
lib/spazio_solazzo_web/live/user/profile_live.ex
Normal file
74
lib/spazio_solazzo_web/live/user/profile_live.ex
Normal 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
|
||||
184
lib/spazio_solazzo_web/live/user/profile_live.html.heex
Normal file
184
lib/spazio_solazzo_web/live/user/profile_live.html.heex
Normal 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>
|
||||
39
lib/spazio_solazzo_web/live_user_auth.ex
Normal file
39
lib/spazio_solazzo_web/live_user_auth.ex
Normal 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
|
||||
87
lib/spazio_solazzo_web/router.ex
Normal file
87
lib/spazio_solazzo_web/router.ex
Normal 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
|
||||
93
lib/spazio_solazzo_web/telemetry.ex
Normal file
93
lib/spazio_solazzo_web/telemetry.ex
Normal 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
134
mix.exs
Normal 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
98
mix.lock
Normal 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"},
|
||||
}
|
||||
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal file
112
priv/gettext/en/LC_MESSAGES/errors.po
Normal 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
109
priv/gettext/errors.pot
Normal 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 ""
|
||||
7
priv/repo/migrations/20260109010248_add_oban.exs
Normal file
7
priv/repo/migrations/20260109010248_add_oban.exs
Normal 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
|
||||
|
|
@ -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
|
||||
196
priv/repo/migrations/20260109010257_setup_resources.exs
Normal file
196
priv/repo/migrations/20260109010257_setup_resources.exs
Normal 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
107
priv/repo/seeds.exs
Normal 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!")
|
||||
93
priv/resource_snapshots/repo/assets/20260109010257.json
Normal file
93
priv/resource_snapshots/repo/assets/20260109010257.json
Normal 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"
|
||||
}
|
||||
244
priv/resource_snapshots/repo/bookings/20260109010257.json
Normal file
244
priv/resource_snapshots/repo/bookings/20260109010257.json
Normal 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"
|
||||
}
|
||||
7
priv/resource_snapshots/repo/extensions.json
Normal file
7
priv/resource_snapshots/repo/extensions.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"ash_functions_version": 5,
|
||||
"installed": [
|
||||
"ash-functions",
|
||||
"citext"
|
||||
]
|
||||
}
|
||||
96
priv/resource_snapshots/repo/spaces/20260109010257.json
Normal file
96
priv/resource_snapshots/repo/spaces/20260109010257.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
103
priv/resource_snapshots/repo/tokens/20260109010257.json
Normal file
103
priv/resource_snapshots/repo/tokens/20260109010257.json
Normal 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"
|
||||
}
|
||||
82
priv/resource_snapshots/repo/users/20260109010257.json
Normal file
82
priv/resource_snapshots/repo/users/20260109010257.json
Normal 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
BIN
priv/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 B |
6
priv/static/images/logo.svg
Normal file
6
priv/static/images/logo.svg
Normal 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
Loading…
Reference in a new issue