Sunset

G2G — Arabic herbal tattoo catalog

A mobile-first Flutter Web tattoo catalog and order manager for Iraq. Customers select designs and quantities, generate portrait images with a tracking code, and contact @ge.to.ge on Instagram or 07778700244 on WhatsApp. Customer accounts and online payments are not required.

Orders, delivery, and employees

Apply all pending migrations, including 202610030001_orders.sql, 202610030002_staff_order_confirmation.sql, 202610040003_order_codes_and_source.sql, 202610040004_manual_orders.sql, and 202610060001_share_source.sql, before using real orders. The order-code migration defaults new orders to Instagram and saves tattoo codes with order items, filling missing codes on older orders from available catalog records. Existing saved sources stay unchanged. Administration order details show each tattoo’s name and code; booking messages omit zero-value discount lines. The default test mode demonstrates the workflow in memory: orders, employees, and settings reset on reload. Production orders persist in Supabase. No production migration or deployment is performed by editing this repository.

Restricted employee logins

Apply all pending migrations through supabase/migrations/202611020002_employee_accounts.sql, deploy the save-employee Edge Function, then release the updated app:

npx supabase db push
npx supabase functions deploy save-employee

These commands target the linked Supabase project; repository changes do not deploy automatically. The function validates the signed-in user with Auth and checks is_admin() before creating an account. Its server-only service role key is provided by Supabase; never place it in Flutter configuration. The Auth Admin API creates the confirmed login, while a database trigger atomically creates/links the employee profile and adds employee_users membership. Passwords are only sent to Auth and are never stored in employee records, app metadata or logs. Public signup stays disabled. Reference: Supabase Auth Admin API.

In الموظفون, add the employee’s name, phone, email and password (at least 8 characters). They can immediately sign in at /admin with those credentials and see only الطلبات (orders) and صور للزبون (customer images). Direct links to other administration sections show orders. They can create, edit, confirm and fulfill orders and export visible catalog images. They cannot manage catalog/offer/storage content, employees, team performance or default delivery settings. Do not add employees to admin_users, which grants full administration access.

Existing name/phone-only employee records ask for credentials when edited. An email already registered in Auth is rejected rather than taking over that account; use a new email or have a trusted administrator link the existing restricted Auth account to the employee record. Linked login emails are read-only in the employee editor; subsequent edits update name and phone. Change existing login credentials through trusted Supabase Auth administration. Removing a user from employee_users immediately revokes order access, independently of the profile.

New orders record the signed-in creator on the server. أُنشئ بواسطة replaces the employee selector; retries, later edits and staff changes preserve the original identity and name. Customer-created orders stay attributed to the customer, including when staff later confirm them. Existing creation events backfill known creators; legacy assignment alone is not treated as proof of who created an order. Historical employee names unavailable in the event/profile data display a generic employee label. Demo mode supports employee creation and sign-in in memory only; accounts and orders reset on reload.

Order checks: flutter test test/employee_account_test.dart test/employee_access_test.dart test/test_admin_service_test.dart test/order_repository_test.dart test/order_ui_test.dart; database authorization and lifecycle checks: run node tool/employee_function_test.cjs and execute supabase/tests/employee_accounts.sql, supabase/tests/employee_access.sql, supabase/tests/orders.sql and supabase/tests/manual_orders.sql against a migrated test database. SQL fixtures are rolled back.

Tattoo offers

Open العروض in administration (/admin/offers) and choose إضافة عرض. Enter a name and a total price in IQD, search existing tattoos by name or code, and select one or more designs (up to 100). Selections remain when searching or loading more results. Each offer includes one piece of every selected tattoo. Save, edit, hide, or delete the offer without changing the original tattoos or their individual prices.

The main catalog shows the newest available offers in a swipeable carousel above search, with previous/next buttons and a page counter. It disappears when no offers are available. Customers can open كل العروض or the price-tag icon (/offers) to see all offers and included artwork. Both the carousel and full offers page show the total price excluding delivery and support Instagram and WhatsApp inquiries. Instagram opens the store chat at @ge.to.ge, where customers mention the offer name; WhatsApp opens a prefilled draft. Customers send messages manually. Offers are displayed for inquiry and do not automatically apply bundle discounts to the regular selection/order flow.

Apply supabase/migrations/202610140001_offers.sql after all earlier pending migrations before using offers with Supabase. Only administrators may save or delete offers. Public reads hide inactive offers and the entire bundle when any included tattoo or all its categories are hidden. Remove a tattoo from its offers before deleting that tattoo; deleting an offer preserves all tattoos. Test-mode offers stay in memory and reset on reload. No production migration is applied by editing this repository.

Checks: flutter test test/offer_repository_test.dart test/offers_ui_test.dart; run supabase/tests/offers.sql against a disposable migrated Supabase database for atomic saves, membership validation, and access control.

Requirements

First setup

flutter pub get

Test mode is enabled by default for now. Run flutter run -d chrome to browse 36 named temporary designs across four categories and six exact sizes, with search tags, bundled placeholder galleries, and no Supabase connection. Search, sorting, pagination, details, selections, and image export work locally. Open /admin and sign in with test@gmail.com / 1234 to test admin management, including category/product edits and image uploads. This demo account works only with USE_TEST_DATA=true; all changes and the login session stay local and reset when the app reloads. The inventory resets with the app; test selections persist separately from real catalog selections.

Control the mode with USE_TEST_DATA in your configuration JSON or --dart-define=USE_TEST_DATA=true / --dart-define=USE_TEST_DATA=false. Restart/rebuild after changing it; hot reload does not apply compile-time flags. Test mode takes precedence even when Supabase credentials are supplied.

To connect to real data, copy config.example.json to config.local.json, set USE_TEST_DATA to false, then provide your project URL and public anon/publishable key. Both values are public by design. Never put a service-role/secret key, database password, or real admin password in this file or any Flutter build.

{
  "USE_TEST_DATA": false,
  "SUPABASE_URL": "https://your-project.supabase.co",
  "SUPABASE_ANON_KEY": "your-public-publishable-key"
}

The legacy name SUPABASE_ANON_KEY accepts either an anon key or the newer publishable key. Configuration is compiled into Flutter Web; changing it requires a rebuild. Temporary inventory lives in lib/repositories/test_catalog_repository.dart and its placeholder images in assets/test_catalog. With test mode disabled and no Supabase configuration, the public catalog shows a retry state and /admin explains the missing setup.

Database, RLS, and Storage

On a new project, run all files in supabase/migrations in filename order in the Supabase SQL editor. For an existing installation, apply any pending migrations in order, including 202610120001_optional_tattoo_names.sql, 202610120002_tattoo_code_name_fallback.sql, and 202610120003_category_thumbnails.sql, before using this version with Supabase. Tattoo codes are required, accept letters, numbers, symbols, and mixed text, preserve leading zeros, and must be unique. Empty or whitespace-only codes are rejected. Tattoo names are optional; tattoos with null, empty, or whitespace-only names display their code throughout the app and generated images. Alternatively link a project and apply migrations using the Supabase CLI:

npx supabase login
npx supabase link --project-ref YOUR_PROJECT_REF
npx supabase db push

The migration creates:

Public bucket URLs remain readable to anyone who already knows the URL after a product is hidden. Deactivation removes catalog discoverability; it is not a revocation of previously shared images. For actual image removal, an administrator can delete the object in Supabase Storage. Do not remove a file still referenced by another record.

Apply 202610300001_specific_body_placements.sql before deploying the expanded body-location choices. The admin editor and customer filters share the same 20 options, ordered from ذراع كامل through الفخذ. Existing arm and hand assignments are retained and displayed as ذراع كامل and الكف.

Create the first administrator

  1. In Supabase Authentication → Users, create an email/password user and confirm the email. No signup screen is exposed in this app.
  2. Copy the user’s UUID and run this only in the trusted SQL editor:
insert into public.admin_users (user_id)
values ('REPLACE-WITH-AUTH-USER-UUID');
  1. Disable public user signups in Supabase Auth settings; email/password sign-in stays enabled. Local configuration already disables signup.
  2. Visit /admin, then sign in. Never share this account with customers.
  3. To revoke access, remove the user’s row from admin_users; database/storage rules apply immediately. Revoke their Auth sessions as well when appropriate.

Add your first category and tattoo

  1. Open التصنيفات in the admin area and tap +.
  2. Enter وشومات سوار, upload its image if available, set the numeric sort order, and save.
  3. Open المنتجات, tap +, add one or more tattoo images, and enter a unique code such as 001. Use إضافة صورة again for each additional image. The first image is the cover for cards and selection exports; use the star button to promote another image, or the delete button to remove an image from the gallery. At least one image is required. Customers can swipe, use arrows, or tap thumbnails to see every image and zoom into details.
  4. Enter an optional customer-facing tattoo name and the required exact width and height in centimeters. Select رجالي, نسائي, or both; choose one or more body placements and one or more categories. Type each search tag and press Enter (or tap +) to add it as a removable chip. Commas and punctuation do not split tags. Up to 30 tags, 64 characters each; normalized duplicates are removed. Saving also includes a pending tag. Price remains optional.
  5. Set ظاهر بالكتالوج, مميز, and وصل حديثاً as needed.
  6. To delete a tattoo, open its editor, choose حذف الوشم, and confirm. It is removed from the catalog; existing order snapshots and stored image files are retained.
  7. Use حفظ وإضافة وشم آخر to keep the audiences, placements, categories, dimensions, and price but clear the code, name, tags, and images for the next design.
  8. To edit, tap a record. Use the sort-order number to reorder. Turn off ظاهر بالكتالوج to hide a design temporarily. Deleting a category that contains products is prevented by its foreign key.

Uploads validate file extension, magic bytes, decoded dimensions, and size. Images above 24 megapixels are rejected before full decode. Original tattoo and category image bytes are stored unmodified. New uploads generate proportional previews with a maximum edge of 480 pixels (without enlarging small images): JPEG at quality 78 for opaque images, or compressed PNG for transparent cutouts. Previews respect camera orientation. Catalog cards load only the preview; opening tattoo details or the full-screen viewer loads the original images. Every gallery image receives a preview so promoting another image to the cover stays fast. Categories store a separate thumbnail URL and use the optimized preview in customer cards, admin lists, and the editor. Existing categories fall back to their current image until re-uploaded. Apply 202610120003_category_thumbnails.sql before uploading category images. Existing uploads keep their current thumbnails; re-upload an image to generate the smaller preview. Failed/cancelled uploads attempt to remove newly staged objects. Replaced, previously saved images are intentionally retained; periodically review unreferenced objects in Storage before deleting them.

Extra images are stored in the ordered additional_images array, with image_url and optional thumbnail_url per image. Existing single-image tattoos need no changes. New uploads removed before saving or left in a cancelled editor are cleaned up; removing a previously saved image only removes its gallery reference and does not delete shared Storage objects.

Run locally

Order delivery updates require supabase/migrations/202611020001_order_delivery_details.sql (after earlier migrations) before releasing the updated app. Orders now move from مؤكد to مرفوع للتوصيل, then packing and shipping. Staff record the governorate, area, nearest landmark and optional ملاحظات للمندوب; landmark and courier notes are included in copied booking details. Existing orders keep their addresses, with the new fields initially blank. Confirmed orders now queue a server-side Prime upload using the employee’s Prime account. A verified successful upload advances the order automatically; exhausted retries leave it confirmed. Apply 202611030001_prime_uploads.sql and 202611030002_prime_order_options.sql, deploy prime-upload and prime-locations, and configure its scheduler as described in Prime setup and recovery.

Staff can add tattoos with إضافة وشومات, remove unwanted tattoos, and save the revised selection with حفظ التعديلات while an order is pending, confirmed, uploaded, or packed. Adding/removing tattoos resets the final-total override to automatic calculation; re-enter any extra discount after revising the selection. Retained tattoos keep their saved names, codes, prices, and item discounts. Orders require 1–100 distinct tattoos, with 1–99 pieces each. Shipping locks the selection in both the editor and database. Apply supabase/migrations/202611020003_edit_order_tattoos.sql after earlier migrations before releasing this editor; verify it with supabase/tests/edit_order_tattoos.sql against a migrated test database.

flutter run -d chrome --dart-define-from-file=config.local.json

For a local Supabase backend:

npx supabase start -x realtime,imgproxy,edge-runtime,logflare,vector,supavisor,studio,mailpit,postgres-meta
npx supabase status

This project’s local ports are API 15421, PostgreSQL 15422, and shadow DB 15420, avoiding common Windows reserved ports. Copy the local API URL and public key into config.local.json. Local migrations run at first startup. Do not run db reset against a database containing important work. supabase/seed.sql is intentionally empty; test fixtures are kept out of production migrations.

Web release and deployment

flutter analyze
flutter test
node tool/share_bridge_test.cjs
flutter build web --release --dart-define-from-file=config.production.json --dart-define=USE_TEST_DATA=false

Upload only build/web to static hosting. Do not deploy source files or private configuration. Deploy over HTTPS for native sharing. Flutter’s initial runtime download is larger than an HTML-only site; the app keeps subsequent product requests paginated and uses thumbnails, lazy grid construction, browser image caching, and locally bundled Arabic typography.

Direct URLs must return the SPA entry point:

Avoid caching index.html, flutter_bootstrap.js, and main.dart.js indefinitely. The supplied Nginx example forces revalidation. Browser installation is never required. The manifest is lightweight; no offline service-worker promise is made. Full offline catalog browsing is not guaranteed, but selected record snapshots persist and loaded data stays visible after pagination errors.

Local release preview, including deep links:

python tool/serve.py --port 8090 --directory build/web

GitHub Pages: deploy with a [d] commit

The workflow in .github/workflows/deploy-pages.yml builds and deploys when the latest commit in a push to the repository’s default branch starts with [d], for example [d] update catalog. Other pushes show a skipped workflow. When pushing several commits together, only the latest commit’s message controls deployment. For squash merges, put the prefix in the final squash commit message.

  1. Push this repository, including the workflow, to GitHub.
  2. In Settings → Pages → Build and deployment, set Source to GitHub Actions.
  3. GitHub Pages deployments always use live Supabase data (USE_TEST_DATA=false). Add repository secrets named SUPABASE_URL and SUPABASE_ANON_KEY under Settings → Secrets and variables → Actions → Secrets. The build stops if either secret is missing. Use the public anon/publishable key; these settings are compiled into the public website, so never use a service-role key. Local configuration files and repository variables are not used by this workflow. Push a new deployment after changing secrets; an existing build will not pick up changes automatically.
  4. Commit and push to the default branch:

    git commit -m "[d] deploy site"
    git push
    

    To deploy without changing files, use git commit --allow-empty -m "[d] deploy site", then push.

The workflow uses Flutter 3.47.4, detects the Pages base path (including repository subdirectories and custom domains), and uploads only build/web. The deployment URL appears in the Actions run’s github-pages environment. It also copies index.html to 404.html so direct links such as /admin load the Flutter router. GitHub Pages still returns HTTP 404 for these deep-link requests; navigation within the app works normally.

See GitHub’s custom Pages workflow documentation for repository setup and deployment permissions.

Custom domain: getoge.com

In Settings → Pages → Custom domain, enter getoge.com and save before updating DNS. At your DNS provider, set these records:

Type Name Value
A @ 185.199.108.153
A @ 185.199.109.153
A @ 185.199.110.153
A @ 185.199.111.153
CNAME www YOUR_USERNAME.github.io

Replace YOUR_USERNAME with the GitHub repository owner’s username. Once GitHub validates DNS and provisions the certificate, enable Enforce HTTPS. Push a new [d] commit after saving the custom domain so the workflow rebuilds for the domain’s root path.

This Actions deployment reads the domain from GitHub Pages settings; no repository CNAME file is required. See GitHub’s custom domain instructions.

Sharing behavior and CORS

Priced images for customers

Open صور للزبون in administration (/admin/images), apply catalog filters, then choose تجهيز صور النتائج للزبون. All matching visible tattoos are exported, including results beyond the first loaded page. These images include names, product codes, dimensions and unit prices in IQD, without an order code, order creation, quantities, or changes to the customer basket. Missing prices display السعر عند الاستفسار; zero remains an explicit price. Download the images and attach them to the customer’s conversation, or use مشاركة مع الزبون when native file sharing is available.

On HTTPS/localhost, exports persist in this browser’s Cache Storage and can be reused after reload. Each request refreshes all matching catalog records before comparing filters, result order, IDs, image URLs, names, codes, dimensions, prices and renderer version. A change generates new images. Admin uploads use new image URLs; replacing artwork externally at an unchanged URL requires cache invalidation. Storage keeps up to 12 exports within 32 MiB, evicting older entries. Clearing site data or browser eviction removes saved exports. If storage is unavailable/full, images can still be downloaded and the screen reports that an automatic copy was not saved. Native demo builds have session-only caching.

Customer order sharing

Apply 202610060001_share_source.sql along with all earlier pending migrations before using the new sharing screen with Supabase. The Instagram/WhatsApp selector saves the destination on the pending order using its private capability; it cannot edit customer details or confirm an order. Failed saves keep the previous destination selected. Manual orders continue to default to Instagram.

SelectionRenderer downloads each selected cover image as bytes, decodes it with Flutter, and draws it proportionally using BoxFit.contain into a 1200 × 1680 PNG. Six designs fit on each page; the last page expands for fewer designs, and one design receives a large single layout. Pages contain the brand, actual artwork, tattoo names/dimensions, page count, and Instagram handle. Full-resolution requests happen on details/export, not initial card loading.

Generation is sequential. Images are decoded to fit their export cells and kept alive until the page has been rendered and encoded as PNG, preventing browser-backed artwork from being released before iOS renders it. Each page’s images are then disposed, including on failure. If an image fails to load, generation fails visibly and the local selections remain. It never shares an incomplete grid while silently skipping an image. Availability and current product data are rechecked before generation; removed designs are reported and the customer can review before retrying.

The customer flow saves the generated selection images, then opens the selected Instagram or WhatsApp chat. It does not show the device’s app/recipient sharing picker. Each generated page is saved separately because mobile browsers may block batches of downloads. Only on iPhone browsers, Save opens a native HTML image view: press and hold the image, choose Save Image / Add to Photos, then tap حفظت الصورة، متابعة. Flutter’s canvas preview itself does not offer the browser’s image-saving menu. The page advances only after that explicit confirmation; closing the view or failing to load the image leaves the current page pending. In-app browsers that suppress image saving show guidance to open the site in Safari. Other browsers, including Android, iPad, and desktop, download the image directly without this view and advance after the request; a request cannot confirm that a file was actually saved. Once all pages have been confirmed/requested, the main button opens the selected chat, with a reminder to check the images and attach them manually. Customers can save an image again. web/share.js keeps image object URLs alive while the preview is open and revokes them when it closes.

Instagram opens https://ig.me/m/ge.to.ge; WhatsApp opens https://wa.me/9647778700244 (Iraq +964, omitting the local leading zero). These links do not attach files automatically. The customer attaches the saved images manually in that chat.

Supabase public Storage normally serves the CORS headers required for byte fetching. If using a custom CDN/proxy, it must also allow GET requests from your site origin and must not strip those headers. Test a real uploaded URL from the deployed site origin, then generate a selection. Do not work around CORS with an untrusted public proxy. All share image rendering uses fetched bytes, not tainted HTML image canvases.

Architecture

No database queries are embedded in UI widgets. Bulk upload calls save_catalog_product with the required name_ar, code, width_cm, height_cm, category_ids, audiences, and body_placements, plus optional tags and gallery fields. Migrations backfill existing category assignments and default existing tattoos to both audiences, with no assumed body placement. Previously unnamed tattoos receive the neutral name وشم عشبي; replace it when editing. Existing missing dimensions are not invented and do not appear as size options until completed. Saved selections from older formats still load. SQL uniqueness is the final protection against concurrent duplicate product codes.

To replace the wordmark, bundle your logo asset in pubspec.yaml and set LOGO_ASSET in the configuration JSON. Both the UI and export renderer read it. The Arabic font is bundled with its OFL license in assets/fonts.

Verification

See docs/VERIFICATION.md for executed checks and remaining real-device/live deployment checks.

flutter analyze
flutter test
node tool/share_bridge_test.cjs

Against a disposable local Supabase backend, execute supabase/tests/catalog_security.sql as PostgreSQL administrator. It runs in a transaction and rolls back fixtures. It covers public filtering, anonymous writes, authenticated non-admin writes, privilege escalation, Storage policies, admin management, duplicate codes, and safe category deletion.

The tests’ synthetic rectangular artwork is only a fixture for measuring export proportions; it is not G2G inventory. Add your own actual tattoo artwork through the admin area.

Technical references